From 260ad8eaa5bd4825b624703f9989689255131478 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:36:56 +0900 Subject: [PATCH 01/17] feat(storekit)!: reconcile durable entitlement state Make TransactionStore the observable process owner, reconcile unfinished revisions to a fixed point, and refresh on subscription status changes. Preserve per-element verification failures, single report authority, cancellation identity, and drain guarantees across direct and background operations. BREAKING CHANGE: Store has been renamed to TransactionStore without a compatibility alias. --- .../Consumer/{main.swift => Consumer.swift} | 2 +- .../FailureReporterDispatcher.swift | 5 +- .../CurrentEntitlementReconciler.swift | 113 ++++ .../EntitlementRefreshCoordinator.swift | 36 +- .../Processing/ProcessingReceipt.swift | 172 ++++-- .../TransactionProcessingCore.swift | 35 +- .../Runtime/RestoreCoordinator.swift | 82 ++- .../Runtime/StoreTransactionPipeline.swift | 48 +- .../Runtime/StoreTransactionRuntime.swift | 202 +++++-- .../Runtime/TaskCompletionBag.swift | 37 +- .../LiveStoreTransactionSource.swift | 24 +- .../LiveTransactionAdapter.swift | 6 + .../StoreTransactionSource.swift | 30 +- .../StorePurchaseOutcome.swift | 2 +- .../StoreTransactionFailure.swift | 37 +- .../StoreTransactionSession.swift | 16 +- .../StoreTransactionSnapshot.swift | 34 +- .../{Store.swift => TransactionStore.swift} | 64 ++- .../CompletedDeliveryRefreshTests.swift | 81 +++ .../EntitlementRefreshCoordinatorTests.swift | 52 +- .../LifecycleResidualTests.swift | 231 ++++++++ .../ReconciliationFixedPointTests.swift | 172 ++++++ .../RuntimeContractTests.swift | 504 ++++++++++++++++-- .../StoreTransactionKitTests/StoreTests.swift | 129 ++++- .../StoreTransactionSessionTests.swift | 429 ++++++++++++++- .../TestSupport.swift | 174 ++++-- .../TransactionProcessingCoreTests.swift | 36 +- 27 files changed, 2423 insertions(+), 330 deletions(-) rename Fixtures/ExternalConsumer/Sources/Consumer/{main.swift => Consumer.swift} (93%) create mode 100644 Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift rename Sources/StoreTransactionKit/{Store.swift => TransactionStore.swift} (72%) create mode 100644 Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift create mode 100644 Tests/StoreTransactionKitTests/LifecycleResidualTests.swift create mode 100644 Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift diff --git a/Fixtures/ExternalConsumer/Sources/Consumer/main.swift b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift similarity index 93% rename from Fixtures/ExternalConsumer/Sources/Consumer/main.swift rename to Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift index 5775fb9..25e993c 100644 --- a/Fixtures/ExternalConsumer/Sources/Consumer/main.swift +++ b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift @@ -8,7 +8,7 @@ private enum EntitlementID: String, Hashable, Sendable { @MainActor struct Consumer { static func main() async throws { - let store = Store( + let store = TransactionStore( handleTransaction: { transaction in print("Handle transaction \(transaction.id)") }, diff --git a/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift b/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift index ed15273..1c5f7f7 100644 --- a/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift +++ b/Sources/StoreTransactionKit/Diagnostics/FailureReporterDispatcher.swift @@ -53,8 +53,9 @@ package actor FailureReporterDispatcher { private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task { - await drainQueue() + worker = Task.detached { [weak self] in + guard let self else { return } + await self.drainQueue() } } diff --git a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift new file mode 100644 index 0000000..3090bb8 --- /dev/null +++ b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift @@ -0,0 +1,113 @@ +import Foundation +import StoreKit + +package final class CurrentEntitlementReconciler: Sendable { + private struct AcceptedTransaction: Sendable { + let snapshot: StoreTransactionSnapshot + let acceptance: ProcessingAcceptance + } + + private let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult + private let queryUnfinished: @Sendable () async -> [StoreTransactionDelivery] + private let core: TransactionProcessingCore + private let failures: FailureReporterDispatcher + + package init( + query: + @escaping @Sendable () async throws + -> CurrentEntitlementQueryResult, + queryUnfinished: + @escaping @Sendable () async -> [StoreTransactionDelivery], + core: TransactionProcessingCore, + failures: FailureReporterDispatcher + ) { + self.currentEntitlements = query + self.queryUnfinished = queryUnfinished + self.core = core + self.failures = failures + } + + package func query() async throws -> [StoreTransactionSnapshot] { + var reconciledRevisions: Set = [] + + while true { + let result = try await currentEntitlements() + let currentRevisions = Set( + result.snapshots.map { Data($0.jwsRepresentation.utf8) } + ) + var iterationRevisions: Set = [] + var acceptedTransactions: [AcceptedTransaction] = [] + + for delivery in await queryUnfinished() { + switch delivery { + case .verified(let envelope): + let canChangeEntitlements = + envelope.value.productType != .consumable + guard + currentRevisions.contains(envelope.revision) + || canChangeEntitlements, + !reconciledRevisions.contains(envelope.revision), + iterationRevisions.insert(envelope.revision).inserted + else { + continue + } + acceptedTransactions.append( + AcceptedTransaction( + snapshot: envelope.value, + acceptance: await core.accept(envelope) + )) + case .unverified: + continue + } + } + + guard !acceptedTransactions.isEmpty else { + await reportVerificationFailures(result.verificationFailures) + return result.snapshots + } + + try await drain(acceptedTransactions) + reconciledRevisions.formUnion(iterationRevisions) + } + } + + private func drain(_ transactions: [AcceptedTransaction]) async throws { + var firstError: (any Error)? + for transaction in transactions { + do { + _ = try await transaction.acceptance.receipt.terminalValue() + } catch { + if transaction.acceptance.role == .owner { + await failures.enqueue( + StoreTransactionBackgroundFailure( + source: .unfinished, + transactionID: transaction.snapshot.id, + productID: transaction.snapshot.productID, + underlyingError: error + )) + } + if firstError == nil { + firstError = error + } + } + } + if let firstError { + throw firstError + } + } + + private func reportVerificationFailures( + _ verificationFailures: [StoreTransactionVerificationError] + ) async { + for failure in verificationFailures { + await failures.enqueue( + StoreTransactionBackgroundFailure( + source: .currentEntitlementVerification, + transactionID: nil, + productID: nil, + underlyingError: failure + ) + ) + } + } +} diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift index 42eea46..c1481d2 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift @@ -1,7 +1,17 @@ import Foundation +package struct EntitlementRefreshReservation: Sendable { + package enum Role: Equatable, Sendable { + case owner + case observer + } + + package let receipt: ProcessingReceipt + package let role: Role +} + package actor EntitlementRefreshCoordinator { - private struct Reservation: Sendable { + private struct PendingReservation: Sendable { let token: UInt64 let receipt: ProcessingReceipt } @@ -11,7 +21,7 @@ package actor EntitlementRefreshCoordinator { private let didChange: @Sendable (StoreEntitlements) async -> Void private var nextToken: UInt64 = 0 private var current: StoreEntitlements? - private var pending: [Reservation] = [] + private var pending: [PendingReservation] = [] private var worker: Task? private var acceptsReservations = true @@ -27,16 +37,25 @@ package actor EntitlementRefreshCoordinator { self.didChange = didChange } - package func reserve() -> ProcessingReceipt { + package func reserve() -> EntitlementRefreshReservation { guard acceptsReservations else { - return .failed(StoreTransactionInternalError.entitlementRefreshClosed) + return EntitlementRefreshReservation( + receipt: .failed( + StoreTransactionInternalError.entitlementRefreshClosed + ), + role: .owner + ) } precondition(nextToken < .max) nextToken += 1 + let role: EntitlementRefreshReservation.Role = + pending.isEmpty ? .owner : .observer let receipt = ProcessingReceipt() - pending.append(Reservation(token: nextToken, receipt: receipt)) + pending.append( + PendingReservation(token: nextToken, receipt: receipt) + ) startWorkerIfNeeded() - return receipt + return EntitlementRefreshReservation(receipt: receipt, role: role) } package func sealAndDrain() async { @@ -48,8 +67,9 @@ package actor EntitlementRefreshCoordinator { private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task { - await runQueries() + worker = Task.detached { [weak self] in + guard let self else { return } + await self.runQueries() } } diff --git a/Sources/StoreTransactionKit/Processing/ProcessingReceipt.swift b/Sources/StoreTransactionKit/Processing/ProcessingReceipt.swift index fdddb1a..a287d27 100644 --- a/Sources/StoreTransactionKit/Processing/ProcessingReceipt.swift +++ b/Sources/StoreTransactionKit/Processing/ProcessingReceipt.swift @@ -1,11 +1,86 @@ import Foundation import Synchronization +package struct ProcessingReceiptWaiterCancellation: Error {} + package final class ProcessingReceipt: Sendable { private typealias ReceiptResult = Result + private final class Waiter: Sendable { + private enum State { + case idle + case waiting(CheckedContinuation) + case cancelled + case terminal(ReceiptResult) + } + + private let state = Mutex(.idle) + + func value() async -> ReceiptResult { + await withCheckedContinuation { continuation in + let immediate = state.withLock { state -> ReceiptResult? in + switch state { + case .idle: + state = .waiting(continuation) + return nil + case .cancelled: + return .failure(ProcessingReceiptWaiterCancellation()) + case .terminal(let result): + return result + case .waiting: + preconditionFailure("A processing receipt waiter was awaited more than once.") + } + } + if let immediate { + continuation.resume(returning: immediate) + } + } + } + + func cancel() { + let continuation = state.withLock { state -> CheckedContinuation? in + switch state { + case .idle: + state = .cancelled + return nil + case .waiting(let continuation): + state = .cancelled + return continuation + case .cancelled, .terminal: + return nil + } + } + continuation?.resume( + returning: .failure(ProcessingReceiptWaiterCancellation()) + ) + } + + func complete(_ result: ReceiptResult) { + let continuation = state.withLock { state -> CheckedContinuation? in + switch state { + case .idle: + state = .terminal(result) + return nil + case .waiting(let continuation): + state = .terminal(result) + return continuation + case .cancelled: + return nil + case .terminal: + preconditionFailure("A processing receipt waiter completed more than once.") + } + } + continuation?.resume(returning: result) + } + } + private enum State { - case pending([UUID: CheckedContinuation]) + case pending([UUID: Waiter]) + case terminal(ReceiptResult) + } + + private enum Registration { + case waiter(id: UUID, waiter: Waiter) case terminal(ReceiptResult) } @@ -26,61 +101,31 @@ package final class ProcessingReceipt: Sendable { } package func value() async throws -> Value { - let waiterID = UUID() - let cancellation = Mutex(false) + guard !Task.isCancelled else { + throw ProcessingReceiptWaiterCancellation() + } - let result = await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - let wasCancelled = cancellation.withLock { $0 } - let immediate = state.withLock { state -> ReceiptResult? in - if wasCancelled { - return .failure(CancellationError()) - } - switch state { - case .pending(var waiters): - waiters[waiterID] = continuation - state = .pending(waiters) - return nil - case .terminal(let result): - return result - } - } - if let immediate { - continuation.resume(returning: immediate) - } + switch registerWaiter() { + case .terminal(let result): + return try result.get() + case .waiter(let id, let waiter): + let result = await withTaskCancellationHandler { + await waiter.value() + } onCancel: { + waiter.cancel() + self.removeWaiter(id) } - } onCancel: { - cancellation.withLock { $0 = true } - let continuation = state.withLock { state -> CheckedContinuation? in - guard case .pending(var waiters) = state else { return nil } - let continuation = waiters.removeValue(forKey: waiterID) - state = .pending(waiters) - return continuation - } - continuation?.resume(returning: .failure(CancellationError())) + return try result.get() } - - return try result.get() } package func terminalValue() async throws -> Value { - let result = await withCheckedContinuation { continuation in - let waiterID = UUID() - let immediate = state.withLock { state -> ReceiptResult? in - switch state { - case .pending(var waiters): - waiters[waiterID] = continuation - state = .pending(waiters) - return nil - case .terminal(let result): - return result - } - } - if let immediate { - continuation.resume(returning: immediate) - } + switch registerWaiter() { + case .terminal(let result): + return try result.get() + case .waiter(_, let waiter): + return try await waiter.value().get() } - return try result.get() } package func succeed(_ value: Value) { @@ -92,15 +137,38 @@ package final class ProcessingReceipt: Sendable { } private func complete(_ result: ReceiptResult) { - let continuations = state.withLock { state -> [CheckedContinuation] in + let waiters = state.withLock { state -> [Waiter] in guard case .pending(let waiters) = state else { preconditionFailure("A processing receipt completed more than once.") } state = .terminal(result) return Array(waiters.values) } - for continuation in continuations { - continuation.resume(returning: result) + for waiter in waiters { + waiter.complete(result) + } + } + + private func registerWaiter() -> Registration { + state.withLock { state in + switch state { + case .pending(var waiters): + let id = UUID() + let waiter = Waiter() + waiters[id] = waiter + state = .pending(waiters) + return .waiter(id: id, waiter: waiter) + case .terminal(let result): + return .terminal(result) + } + } + } + + private func removeWaiter(_ id: UUID) { + state.withLock { state in + guard case .pending(var waiters) = state else { return } + waiters.removeValue(forKey: id) + state = .pending(waiters) } } } diff --git a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift index 3a0cd61..62b54ff 100644 --- a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift +++ b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift @@ -1,5 +1,16 @@ import Foundation +package struct ProcessingAcceptance: Sendable { + package enum Role: Equatable, Sendable { + case owner + case inFlightObserver + case completedObserver + } + + package let receipt: ProcessingReceipt + package let role: Role +} + package actor TransactionProcessingCore { private struct QueuedOperation: Sendable { let envelope: ProcessingEnvelope @@ -24,22 +35,31 @@ package actor TransactionProcessingCore { package func accept( _ envelope: ProcessingEnvelope - ) -> ProcessingReceipt { + ) -> ProcessingAcceptance { guard acceptsInput else { - return .failed(StoreTransactionInternalError.inputClosed) + return ProcessingAcceptance( + receipt: .failed(StoreTransactionInternalError.inputClosed), + role: .owner + ) } if completed.contains(envelope.revision) { - return .succeeded(envelope.value) + return ProcessingAcceptance( + receipt: .succeeded(envelope.value), + role: .completedObserver + ) } if let receipt = inFlight[envelope.revision] { - return receipt + return ProcessingAcceptance( + receipt: receipt, + role: .inFlightObserver + ) } let receipt = ProcessingReceipt() inFlight[envelope.revision] = receipt queue.append(QueuedOperation(envelope: envelope, receipt: receipt)) startWorkerIfNeeded() - return receipt + return ProcessingAcceptance(receipt: receipt, role: .owner) } package func finishInputAndDrain() async { @@ -52,8 +72,9 @@ package actor TransactionProcessingCore { private func startWorkerIfNeeded() { guard worker == nil else { return } - worker = Task { - await drainQueue() + worker = Task.detached { [weak self] in + guard let self else { return } + await self.drainQueue() } } diff --git a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift index fc199cf..a119271 100644 --- a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift +++ b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift @@ -1,7 +1,23 @@ +package struct RestoreReservation: Sendable { + package enum Role: Equatable, Sendable { + case owner + case observer + } + + package let receipt: ProcessingReceipt + package let role: Role +} + +package struct RestoreCoordinatorFailure: Error { + package let underlyingError: any Error + package let reportsWhenAbandoned: Bool +} + package actor RestoreCoordinator { private struct InFlight: Sendable { let id: UInt64 - let task: Task + let receipt: ProcessingReceipt + let task: Task } private let synchronize: @Sendable () async throws -> Void @@ -17,36 +33,64 @@ package actor RestoreCoordinator { self.entitlements = entitlements } - package func restore() async throws -> StoreEntitlements { + package func reserve() -> RestoreReservation { if let inFlight { - return try await inFlight.task.value + return RestoreReservation( + receipt: inFlight.receipt, + role: .observer + ) } precondition(nextID < .max) nextID += 1 let id = nextID + let receipt = ProcessingReceipt() let synchronize = synchronize let entitlements = entitlements - let task = Task { - try await synchronize() - let receipt = await entitlements.reserve() - return try await receipt.terminalValue() - } - inFlight = InFlight(id: id, task: task) - - do { - let value = try await task.value - clearInFlight(id: id) - return value - } catch { - clearInFlight(id: id) - throw error + let task = Task.detached { [weak self] in + let result: Result + do { + try await synchronize() + } catch { + await self?.complete( + id: id, + result: .failure( + RestoreCoordinatorFailure( + underlyingError: error, + reportsWhenAbandoned: true + )) + ) + return + } + + let refresh = await entitlements.reserve() + do { + result = .success(try await refresh.receipt.terminalValue()) + } catch { + result = .failure( + RestoreCoordinatorFailure( + underlyingError: error, + reportsWhenAbandoned: refresh.role == .owner + )) + } + await self?.complete(id: id, result: result) } + inFlight = InFlight(id: id, receipt: receipt, task: task) + return RestoreReservation(receipt: receipt, role: .owner) } - private func clearInFlight(id: UInt64) { - guard inFlight?.id == id else { return } + private func complete( + id: UInt64, + result: Result + ) { + guard let active = inFlight, active.id == id else { return } inFlight = nil + switch result { + case .success(let value): + active.receipt.succeed(value) + case .failure(let error): + active.receipt.fail(error) + } } isolated deinit { diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift index fd6d75d..713777a 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift @@ -17,7 +17,7 @@ package final class StoreTransactionPipeline: Sendable { _ delivery: StoreTransactionDelivery ) async throws -> ( snapshot: StoreTransactionSnapshot, - receipt: ProcessingReceipt + acceptance: ProcessingAcceptance ) { switch delivery { case .verified(let envelope): @@ -32,11 +32,11 @@ package final class StoreTransactionPipeline: Sendable { source: StoreTransactionBackgroundFailure.Source ) async { let snapshot: StoreTransactionSnapshot? - let receipt: ProcessingReceipt + let acceptance: ProcessingAcceptance do { let accepted = try await accept(delivery) snapshot = accepted.snapshot - receipt = accepted.receipt + acceptance = accepted.acceptance } catch { await failures.enqueue( StoreTransactionBackgroundFailure( @@ -48,9 +48,22 @@ package final class StoreTransactionPipeline: Sendable { return } + await processAcceptedBackground( + snapshot: snapshot, + acceptance: acceptance, + source: source + ) + } + + package func processAcceptedBackground( + snapshot: StoreTransactionSnapshot?, + acceptance: ProcessingAcceptance, + source: StoreTransactionBackgroundFailure.Source + ) async { do { - _ = try await receipt.terminalValue() + _ = try await acceptance.receipt.terminalValue() } catch { + guard case .owner = acceptance.role else { return } await failures.enqueue( StoreTransactionBackgroundFailure( source: source, @@ -61,10 +74,14 @@ package final class StoreTransactionPipeline: Sendable { return } + if case .inFlightObserver = acceptance.role { + return + } + let refresh = await entitlements.reserve() do { - let refresh = await entitlements.reserve() - _ = try await refresh.terminalValue() + _ = try await refresh.receipt.terminalValue() } catch { + guard refresh.role == .owner else { return } await failures.enqueue( StoreTransactionBackgroundFailure( source: .entitlementRefresh, @@ -75,23 +92,20 @@ package final class StoreTransactionPipeline: Sendable { } } - package func reportAbandoned( - operation: StoreTransactionOperation, - snapshot: StoreTransactionSnapshot?, - receipt: ProcessingReceipt - ) async { + package func refreshEntitlements() async { + let refresh = await entitlements.reserve() do { - _ = try await receipt.terminalValue() - let refresh = await entitlements.reserve() - _ = try await refresh.terminalValue() + _ = try await refresh.receipt.terminalValue() } catch { + guard refresh.role == .owner else { return } await failures.enqueue( StoreTransactionBackgroundFailure( - source: .abandonedDirectOperation(operation), - transactionID: snapshot?.id, - productID: snapshot?.productID, + source: .entitlementRefresh, + transactionID: nil, + productID: nil, underlyingError: error )) } } + } diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index c594dda..0ba5aa8 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -1,5 +1,10 @@ import StoreKit +private struct DirectOperationFailure: Error { + let underlyingError: any Error + let reportsWhenAbandoned: Bool +} + package final class StoreTransactionRuntime: Sendable { private let source: StoreTransactionSource private let core: TransactionProcessingCore @@ -9,10 +14,12 @@ package final class StoreTransactionRuntime: Sendable { private let restoreCoordinator: RestoreCoordinator private let operations = FiniteOperationRegistry() private let readinessLease: FiniteOperationLease + private let subscriptionStatusReadiness: ProcessingReceipt private let producerCancellation = TaskCancellationBag() private let finiteTasks = TaskCompletionBag() private let updatesTask: Task private let unfinishedTask: Task + private let subscriptionStatusTask: Task package init( sessionID: UUID, @@ -30,14 +37,20 @@ package final class StoreTransactionRuntime: Sendable { sessionID: sessionID, handle: handleTransaction ) - let entitlements = EntitlementRefreshCoordinator( + let failures = FailureReporterDispatcher( sessionID: sessionID, + report: reportFailure + ) + let currentEntitlements = CurrentEntitlementReconciler( query: source.currentEntitlements, - didChange: entitlementsDidChange + queryUnfinished: source.queryUnfinished, + core: core, + failures: failures ) - let failures = FailureReporterDispatcher( + let entitlements = EntitlementRefreshCoordinator( sessionID: sessionID, - report: reportFailure + query: currentEntitlements.query, + didChange: entitlementsDidChange ) let pipeline = StoreTransactionPipeline( core: core, @@ -53,19 +66,36 @@ package final class StoreTransactionRuntime: Sendable { entitlements: entitlements ) self.readinessLease = operations.begin()! + let subscriptionStatusReadiness = ProcessingReceipt() + self.subscriptionStatusReadiness = subscriptionStatusReadiness - self.updatesTask = Task { + self.updatesTask = Task.detached { await source.runUpdates { delivery in await pipeline.processBackground(delivery, source: .updates) } } - self.unfinishedTask = Task { + self.unfinishedTask = Task.detached { await source.runUnfinished { delivery in await pipeline.processBackground(delivery, source: .unfinished) } } + self.subscriptionStatusTask = Task.detached { + await source.runSubscriptionStatusUpdates { + do { + _ = try await subscriptionStatusReadiness.value() + } catch is ProcessingReceiptWaiterCancellation { + return + } catch { + preconditionFailure( + "Subscription status readiness cannot fail: \(error)" + ) + } + await pipeline.refreshEntitlements() + } + } producerCancellation.insert(updatesTask) producerCancellation.insert(unfinishedTask) + producerCancellation.insert(subscriptionStatusTask) } package func beginOperation() -> FiniteOperationLeases? { @@ -73,12 +103,19 @@ package final class StoreTransactionRuntime: Sendable { } package func readiness() async throws -> StoreTransactionReadiness { - defer { readinessLease.end() } + defer { + subscriptionStatusReadiness.succeed(()) + readinessLease.end() + } await unfinishedTask.value - let receipt = await entitlements.reserve() - return StoreTransactionReadiness( - entitlements: try await receipt.value() - ) + let reservation = await entitlements.reserve() + do { + return StoreTransactionReadiness( + entitlements: try await reservation.receipt.value() + ) + } catch is ProcessingReceiptWaiterCancellation { + throw CancellationError() + } } package func process( @@ -87,40 +124,10 @@ package final class StoreTransactionRuntime: Sendable { ) async throws -> StorePurchaseOutcome { switch result { case .success(let verificationResult): - let accepted: - ( - snapshot: StoreTransactionSnapshot, - receipt: ProcessingReceipt - ) - do { - accepted = try await pipeline.accept( - source.purchaseDelivery(verificationResult) - ) - } catch { - leases.work.end() - leases.observer.end() - throw error - } - let operationReceipt = ProcessingReceipt() - let entitlements = entitlements - let task = Task { - defer { leases.work.end() } - do { - let snapshot = try await accepted.receipt.terminalValue() - let refresh = await entitlements.reserve() - _ = try await refresh.terminalValue() - operationReceipt.succeed(snapshot) - } catch { - operationReceipt.fail(error) - } - } - finiteTasks.insert(task) - return try await outcome( - receipt: operationReceipt, - operation: .processPurchase, - snapshot: accepted.snapshot, - observerLease: leases.observer - ) { .completed($0) } + return try await process( + source.purchaseDelivery(verificationResult), + leases: leases + ) case .pending: do { try Task.checkCancellation() @@ -150,6 +157,63 @@ package final class StoreTransactionRuntime: Sendable { } } + package func process( + _ delivery: StoreTransactionDelivery, + leases: FiniteOperationLeases + ) async throws -> StorePurchaseOutcome { + let accepted: + ( + snapshot: StoreTransactionSnapshot, + acceptance: ProcessingAcceptance + ) + do { + accepted = try await pipeline.accept(delivery) + } catch { + leases.work.end() + leases.observer.end() + throw error + } + let operationReceipt = ProcessingReceipt() + let entitlements = entitlements + let task = Task { + defer { leases.work.end() } + let snapshot: StoreTransactionSnapshot + do { + snapshot = try await accepted.acceptance.receipt + .terminalValue() + } catch { + operationReceipt.fail( + DirectOperationFailure( + underlyingError: error, + reportsWhenAbandoned: + accepted.acceptance.role == .owner + ) + ) + return + } + + let refresh = await entitlements.reserve() + do { + _ = try await refresh.receipt.terminalValue() + operationReceipt.succeed(snapshot) + } catch { + operationReceipt.fail( + DirectOperationFailure( + underlyingError: error, + reportsWhenAbandoned: refresh.role == .owner + ) + ) + } + } + finiteTasks.insert(task) + return try await outcome( + receipt: operationReceipt, + operation: .processPurchase, + snapshot: accepted.snapshot, + observerLease: leases.observer + ) { .completed($0) } + } + package func currentEntitlements( leases: FiniteOperationLeases ) async throws -> StoreEntitlements { @@ -157,11 +221,18 @@ package final class StoreTransactionRuntime: Sendable { let entitlements = entitlements let task = Task { defer { leases.work.end() } + let refresh = await entitlements.reserve() do { - let refresh = await entitlements.reserve() - operationReceipt.succeed(try await refresh.terminalValue()) + operationReceipt.succeed( + try await refresh.receipt.terminalValue() + ) } catch { - operationReceipt.fail(error) + operationReceipt.fail( + DirectOperationFailure( + underlyingError: error, + reportsWhenAbandoned: refresh.role == .owner + ) + ) } } finiteTasks.insert(task) @@ -201,16 +272,27 @@ package final class StoreTransactionRuntime: Sendable { package func restorePurchases( leases: FiniteOperationLeases ) async throws -> StoreEntitlements { + let restore = await restoreCoordinator.reserve() let operationReceipt = ProcessingReceipt() - let restoreCoordinator = restoreCoordinator let task = Task { defer { leases.work.end() } do { operationReceipt.succeed( - try await restoreCoordinator.restore() + try await restore.receipt.terminalValue() + ) + } catch let failure as RestoreCoordinatorFailure { + operationReceipt.fail( + DirectOperationFailure( + underlyingError: failure.underlyingError, + reportsWhenAbandoned: + restore.role == .owner + && failure.reportsWhenAbandoned + ) ) } catch { - operationReceipt.fail(error) + preconditionFailure( + "RestoreCoordinator exposed an unclassified failure: \(error)" + ) } } finiteTasks.insert(task) @@ -226,10 +308,11 @@ package final class StoreTransactionRuntime: Sendable { producerCancellation.cancel() await updatesTask.value await unfinishedTask.value + await subscriptionStatusTask.value await operations.stopAdmissionAndWait() await finiteTasks.waitForAll() - await core.finishInputAndDrain() await entitlements.sealAndDrain() + await core.finishInputAndDrain() await failures.sealAndDrain() producerCancellation.removeAll() } @@ -250,12 +333,22 @@ package final class StoreTransactionRuntime: Sendable { let value = try await receipt.value() observerLease.end() return transform(value) - } catch is CancellationError { + } catch is ProcessingReceiptWaiterCancellation { let failures = failures let task = Task { defer { observerLease.end() } do { _ = try await receipt.terminalValue() + } catch let failure as DirectOperationFailure { + guard failure.reportsWhenAbandoned else { return } + await failures.enqueue( + StoreTransactionBackgroundFailure( + source: .abandonedDirectOperation(operation), + transactionID: snapshot?.id, + productID: snapshot?.productID, + underlyingError: failure.underlyingError + ) + ) } catch { await failures.enqueue( StoreTransactionBackgroundFailure( @@ -268,6 +361,9 @@ package final class StoreTransactionRuntime: Sendable { } finiteTasks.insert(task) throw CancellationError() + } catch let failure as DirectOperationFailure { + observerLease.end() + throw failure.underlyingError } catch { observerLease.end() throw error diff --git a/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift b/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift index 312938b..8264cd8 100644 --- a/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift +++ b/Sources/StoreTransactionKit/Runtime/TaskCompletionBag.swift @@ -2,13 +2,18 @@ import Foundation import Synchronization package final class TaskCompletionBag: Sendable { - private let tasks = Mutex<[UUID: Task]>([:]) + private struct State { + var tasks: [UUID: Task] = [:] + var emptyWaiters: [CheckedContinuation] = [] + } + + private let state = Mutex(State()) package init() {} package func insert(_ task: Task) { let id = UUID() - tasks.withLock { $0[id] = task } + state.withLock { $0.tasks[id] = task } Task { [weak self] in await task.value self?.remove(id) @@ -16,25 +21,39 @@ package final class TaskCompletionBag: Sendable { } package func waitForAll() async { - let snapshot = tasks.withLock { Array($0.values) } - for task in snapshot { - await task.value + await withCheckedContinuation { continuation in + let isEmpty = state.withLock { state in + guard !state.tasks.isEmpty else { return true } + state.emptyWaiters.append(continuation) + return false + } + if isEmpty { + continuation.resume() + } } - tasks.withLock { $0.removeAll(keepingCapacity: false) } } package func cancel() { - let snapshot = tasks.withLock { Array($0.values) } + let snapshot = state.withLock { Array($0.tasks.values) } for task in snapshot { task.cancel() } } package func retainedTaskCount() -> Int { - tasks.withLock { $0.count } + state.withLock { $0.tasks.count } } private func remove(_ id: UUID) { - _ = tasks.withLock { $0.removeValue(forKey: id) } + let waiters = state.withLock { state -> [CheckedContinuation] in + state.tasks.removeValue(forKey: id) + guard state.tasks.isEmpty else { return [] } + let waiters = state.emptyWaiters + state.emptyWaiters.removeAll(keepingCapacity: false) + return waiters + } + for waiter in waiters { + waiter.resume() + } } } diff --git a/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift index 8c32988..4c55dc2 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift @@ -12,12 +12,32 @@ package extension StoreTransactionSource { await consume(LiveTransactionAdapter.delivery(result)) } }, + runSubscriptionStatusUpdates: { consume in + for await _ in Product.SubscriptionInfo.Status.updates { + await consume() + } + }, currentEntitlements: { var snapshots: [StoreTransactionSnapshot] = [] + var verificationFailures: [StoreTransactionVerificationError] = [] for await result in Transaction.currentEntitlements { - snapshots.append(try LiveTransactionAdapter.snapshot(result)) + do { + snapshots.append(try LiveTransactionAdapter.snapshot(result)) + } catch let error as StoreTransactionVerificationError { + verificationFailures.append(error) + } } - return snapshots + return CurrentEntitlementQueryResult( + snapshots: snapshots, + verificationFailures: verificationFailures + ) + }, + queryUnfinished: { + var deliveries: [StoreTransactionDelivery] = [] + for await result in Transaction.unfinished { + deliveries.append(LiveTransactionAdapter.delivery(result)) + } + return deliveries }, history: { productID in var snapshots: [StoreTransactionSnapshot] = [] diff --git a/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift b/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift index 8258661..8957010 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift @@ -51,6 +51,12 @@ package enum LiveTransactionAdapter { productID: transaction.productID, subscriptionGroupID: transaction.subscriptionGroupID, productType: transaction.productType, + environment: transaction.environment, + offer: transaction.offer, + storefrontID: transaction.storefront.id, + storefrontCountryCode: transaction.storefront.countryCode, + price: transaction.price, + currency: transaction.currency, purchaseDate: transaction.purchaseDate, originalPurchaseDate: transaction.originalPurchaseDate, expirationDate: transaction.expirationDate, diff --git a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift index 1aba9be..a1fe73c 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift @@ -5,6 +5,19 @@ package enum StoreTransactionDelivery: Sendable { case unverified(any Error) } +package struct CurrentEntitlementQueryResult: Sendable { + package let snapshots: [StoreTransactionSnapshot] + package let verificationFailures: [StoreTransactionVerificationError] + + package init( + snapshots: [StoreTransactionSnapshot], + verificationFailures: [StoreTransactionVerificationError] + ) { + self.snapshots = snapshots + self.verificationFailures = verificationFailures + } +} + package struct StoreTransactionSource: Sendable { package let runUpdates: @Sendable ( @@ -14,7 +27,12 @@ package struct StoreTransactionSource: Sendable { @Sendable ( @Sendable (StoreTransactionDelivery) async -> Void ) async -> Void - package let currentEntitlements: @Sendable () async throws -> [StoreTransactionSnapshot] + package let runSubscriptionStatusUpdates: + @Sendable ( + @Sendable () async -> Void + ) async -> Void + package let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult + package let queryUnfinished: @Sendable () async -> [StoreTransactionDelivery] package let history: @Sendable (Product.ID) async throws -> [StoreTransactionSnapshot] package let synchronize: @Sendable () async throws -> Void package let purchaseDelivery: @Sendable (VerificationResult) -> StoreTransactionDelivery @@ -28,9 +46,15 @@ package struct StoreTransactionSource: Sendable { @escaping @Sendable ( @Sendable (StoreTransactionDelivery) async -> Void ) async -> Void, + runSubscriptionStatusUpdates: + @escaping @Sendable ( + @Sendable () async -> Void + ) async -> Void, currentEntitlements: @escaping @Sendable () async throws - -> [StoreTransactionSnapshot], + -> CurrentEntitlementQueryResult, + queryUnfinished: + @escaping @Sendable () async -> [StoreTransactionDelivery], history: @escaping @Sendable (Product.ID) async throws -> [StoreTransactionSnapshot], @@ -42,7 +66,9 @@ package struct StoreTransactionSource: Sendable { ) { self.runUpdates = runUpdates self.runUnfinished = runUnfinished + self.runSubscriptionStatusUpdates = runSubscriptionStatusUpdates self.currentEntitlements = currentEntitlements + self.queryUnfinished = queryUnfinished self.history = history self.synchronize = synchronize self.purchaseDelivery = purchaseDelivery diff --git a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift index 003ef59..57bb24f 100644 --- a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift +++ b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift @@ -1,5 +1,5 @@ /// The semantic result of processing a direct StoreKit purchase result. -public enum StorePurchaseOutcome: Sendable { +public enum StorePurchaseOutcome: Sendable, Hashable { /// StoreTransactionKit verified, durably handled, finished, and refreshed the transaction. case completed(StoreTransactionSnapshot) diff --git a/Sources/StoreTransactionKit/StoreTransactionFailure.swift b/Sources/StoreTransactionKit/StoreTransactionFailure.swift index 7767164..0bce554 100644 --- a/Sources/StoreTransactionKit/StoreTransactionFailure.swift +++ b/Sources/StoreTransactionKit/StoreTransactionFailure.swift @@ -1,8 +1,8 @@ import Foundation /// A store operation that can appear in lifecycle and diagnostic errors. -public enum StoreTransactionOperation: Sendable { - /// Processing a direct ``StoreKit/Product/PurchaseResult``. +public enum StoreTransactionOperation: Sendable, Hashable { + /// Processing a direct `Product.PurchaseResult`. case processPurchase /// Refreshing the current entitlement projection. @@ -27,16 +27,19 @@ package enum StoreTransactionCallback: Sendable { /// A failure produced by work that has no attached public caller. public struct StoreTransactionBackgroundFailure: Error, Sendable { /// The background path that produced the failure. - public enum Source: Sendable { - /// A delivery from ``StoreKit/Transaction/updates``. + public enum Source: Sendable, Hashable { + /// A delivery from `Transaction.updates`. case updates - /// A delivery from ``StoreKit/Transaction/unfinished`` during startup. + /// A delivery from `Transaction.unfinished` during startup. case unfinished /// A refresh requested after background processing completed. case entitlementRefresh + /// An unverified element omitted from the current entitlement projection. + case currentEntitlementVerification + /// A direct operation whose caller cancelled after the session accepted it. case abandonedDirectOperation(StoreTransactionOperation) } @@ -67,7 +70,7 @@ public struct StoreTransactionBackgroundFailure: Error, Sendable { } /// An error caused by using a store outside its documented lifecycle. -public enum StoreTransactionError: Error, Sendable { +public enum StoreTransactionError: Error, Sendable, Hashable { /// The store has begun its shared close operation and accepts no new work. case closing @@ -77,10 +80,13 @@ public enum StoreTransactionError: Error, Sendable { /// StoreKit returned a purchase result unknown to this framework version. case unknownPurchaseResult - /// A consumer callback attempted to synchronously reenter its own session. + /// A consumer callback attempted to reenter its own session. /// - /// Reentrancy is rejected because a callback can otherwise wait behind - /// itself or attempt to close the dispatcher that is executing it. + /// Reentrancy with propagated callback context is rejected because a + /// callback can otherwise wait behind itself or attempt to close the + /// dispatcher that is executing it. Callbacks must also avoid awaiting a + /// detached task that calls the same store: detached tasks don't carry the + /// callback context but can still create the same dependency cycle. case reentrantOperation(operation: StoreTransactionOperation) } @@ -89,12 +95,19 @@ package enum StoreTransactionLifecycleError: Error, Sendable { case notStarted } -package struct StoreTransactionVerificationError: LocalizedError, Sendable { - package let underlyingError: any Error +/// An error StoreKit returns when transaction verification fails. +public struct StoreTransactionVerificationError: LocalizedError, Sendable { + /// The verification error supplied by StoreKit. + public let underlyingError: any Error - package var errorDescription: String? { + /// A localized description of the underlying StoreKit verification error. + public var errorDescription: String? { underlyingError.localizedDescription } + + package init(underlyingError: any Error) { + self.underlyingError = underlyingError + } } package enum StoreTransactionInternalError: Error, Sendable { diff --git a/Sources/StoreTransactionKit/StoreTransactionSession.swift b/Sources/StoreTransactionKit/StoreTransactionSession.swift index 74bfe23..f5d5bad 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSession.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSession.swift @@ -32,9 +32,10 @@ package actor StoreTransactionSession { /// /// - Parameters: /// - handleTransaction: Applies the durable business effect for a verified - /// transaction. The handler must be idempotent because StoreKit delivery - /// is at least once. StoreTransactionKit calls `finish()` only after this - /// closure returns successfully. + /// transaction. StoreTransactionKit exposes an at-least-once + /// handler-delivery contract, so the handler must be idempotent. + /// StoreTransactionKit calls `finish()` only after this closure returns + /// successfully. /// - entitlementsDidChange: Receives complete, ordered entitlement /// snapshots when the current entitlement content changes. /// - reportFailure: Receives failures from process-owned work that has no @@ -82,7 +83,7 @@ package actor StoreTransactionSession { /// Starts transaction monitoring, reconciles unfinished work, and publishes initial entitlements. /// - /// Both StoreKit producer tasks are retained before this method first + /// All StoreKit producer tasks are retained before this method first /// suspends. The method returns only after the startup unfinished sequence, /// the initial entitlement query, and any initial entitlement callback have /// completed. @@ -166,7 +167,8 @@ package actor StoreTransactionSession { /// /// - Parameter productID: The StoreKit product identifier to query. /// - Returns: Verified snapshots ordered by purchase date, signed date, and - /// transaction identifier descending, with exact JWS bytes as a final tie breaker. + /// transaction identifier descending, then exact JWS UTF-8 bytes + /// ascending. /// - Throws: A StoreKit verification or query error, a lifecycle or callback /// reentrancy error, or `CancellationError` for an abandoned waiter. package func history( @@ -187,7 +189,9 @@ package actor StoreTransactionSession { /// /// - Returns: The entitlement publication from a query reserved after sync succeeds. /// - Throws: A synchronization, verification, query, lifecycle, callback - /// reentrancy, or caller cancellation error. + /// reentrancy, or caller cancellation error. StoreKit may throw + /// ``StoreKit/StoreKitError/userCancelled`` when the user dismisses + /// authentication; callers should treat that as a normal user outcome. package func restorePurchases() async throws -> StoreEntitlements { let runtime = try runningRuntime(operation: .restorePurchases) guard let leases = runtime.beginOperation() else { diff --git a/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift b/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift index 338161f..fcfca65 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSnapshot.swift @@ -4,7 +4,7 @@ import StoreKit /// An immutable projection of a transaction that StoreKit verified. /// /// StoreTransactionKit creates snapshots only after StoreKit verification -/// succeeds. A snapshot never owns the underlying ``StoreKit/Transaction`` and +/// succeeds. A snapshot never owns the underlying `Transaction` and /// exposes no authority to finish it. public struct StoreTransactionSnapshot: Sendable, Hashable { /// The identifier of this transaction revision's transaction. @@ -22,6 +22,26 @@ public struct StoreTransactionSnapshot: Sendable, Hashable { /// The StoreKit product type associated with the transaction. public let productType: Product.ProductType + /// The App Store server environment that generated and signed the transaction. + public let environment: AppStore.Environment + + /// The offer that applies to the transaction, when applicable. + public let offer: Transaction.Offer? + + /// The Apple-defined identifier of the storefront associated with the transaction. + public let storefrontID: String + + /// The ISO 3166-1 alpha-3 country code of the transaction's storefront. + public let storefrontCountryCode: String + + /// The total price StoreKit recorded for the transaction, in units of ``currency``. + /// + /// Use App Store Connect reporting tools for financial and accounting purposes. + public let price: Decimal? + + /// The currency of ``price``. + public let currency: Locale.Currency? + /// The date on which the customer purchased this transaction. public let purchaseDate: Date @@ -68,6 +88,12 @@ public struct StoreTransactionSnapshot: Sendable, Hashable { productID: String, subscriptionGroupID: String?, productType: Product.ProductType, + environment: AppStore.Environment, + offer: Transaction.Offer?, + storefrontID: String, + storefrontCountryCode: String, + price: Decimal?, + currency: Locale.Currency?, purchaseDate: Date, originalPurchaseDate: Date, expirationDate: Date?, @@ -86,6 +112,12 @@ public struct StoreTransactionSnapshot: Sendable, Hashable { self.productID = productID self.subscriptionGroupID = subscriptionGroupID self.productType = productType + self.environment = environment + self.offer = offer + self.storefrontID = storefrontID + self.storefrontCountryCode = storefrontCountryCode + self.price = price + self.currency = currency self.purchaseDate = purchaseDate self.originalPurchaseDate = originalPurchaseDate self.expirationDate = expirationDate diff --git a/Sources/StoreTransactionKit/Store.swift b/Sources/StoreTransactionKit/TransactionStore.swift similarity index 72% rename from Sources/StoreTransactionKit/Store.swift rename to Sources/StoreTransactionKit/TransactionStore.swift index 169d3b5..ea8551c 100644 --- a/Sources/StoreTransactionKit/Store.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -7,6 +7,8 @@ import StoreKit /// Create one store in the application's process composition root. The store /// starts transaction monitoring during initialization and publishes complete /// current-entitlement snapshots on the main actor. +/// Public operations other than ``close()`` wait for the startup attempt, +/// including durable handling of startup unfinished transactions, to complete. /// /// `EntitlementID` is an app-defined, string-backed identifier. Values whose /// raw values match a current StoreKit entitlement appear in @@ -15,28 +17,31 @@ import StoreKit /// never hidden. @MainActor @Observable -public final class Store +public final class TransactionStore where EntitlementID: RawRepresentable & Hashable & Sendable, EntitlementID.RawValue == String { - /// The latest complete current-entitlement projection. + /// The latest verified current-entitlement projection. /// /// The value is `nil` until the first entitlement query succeeds. An empty /// projection is non-`nil` and means StoreKit reported no current - /// entitlements. + /// entitlements. Elements that StoreKit can't verify are omitted and + /// reported through the failure callback. public private(set) var entitlements: StoreEntitlements? - /// App-defined identifiers represented by the latest current entitlements. + /// App-defined identifiers represented by the latest active entitlements. /// /// The value is `nil` until the first entitlement query succeeds. An empty /// set is non-`nil` and means none of the app-defined identifiers is - /// currently entitled. + /// currently entitled. Transactions superseded by a subscription upgrade + /// remain available through ``entitlements`` but don't appear in this set. public var activeEntitlements: Set? { entitlements.map { entitlements in Set( entitlements.transactions.compactMap { - EntitlementID(rawValue: $0.productID) + guard !$0.isUpgraded else { return nil } + return EntitlementID(rawValue: $0.productID) }) } } @@ -57,11 +62,15 @@ where /// /// - Parameters: /// - handleTransaction: Applies the durable business effect for a verified - /// transaction. The handler must be idempotent because StoreKit delivery - /// is at least once. StoreTransactionKit calls `finish()` only after the - /// closure returns successfully. + /// transaction. StoreTransactionKit exposes an at-least-once + /// handler-delivery contract, so the handler must be idempotent. + /// StoreTransactionKit calls `finish()` only after the closure returns + /// successfully. The handler must not call back into the same store, + /// directly or through an awaited child task, because doing so creates a + /// dependency cycle with the operation being handled. /// - reportFailure: Receives failures from process-owned transaction work - /// that has no attached public caller. + /// that has no attached public caller. This callback must not call back + /// into the same store, directly or through an awaited child task. public convenience init( handleTransaction: @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, @@ -82,7 +91,7 @@ where reportFailure: @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void ) { - let owner = StoreOwner() + let owner = TransactionStoreOwner() let sessionID = UUID() let startupCompletion = ProcessingReceipt() let transactionSession = StoreTransactionSession( @@ -103,11 +112,12 @@ where defer { startupCompletion.succeed(()) } do { _ = try await transactionSession.start() - } catch is CancellationError { + } catch { // Explicit close cancels only this readiness waiter. The // session's close operation owns draining accepted work. - } catch { - guard !Task.isCancelled else { return } + guard !Task.isCancelled, self?.entitlements == nil else { + return + } self?.startupError = error } } @@ -116,7 +126,7 @@ where /// Processes a direct result from custom purchase UI. /// /// StoreKit views deliver successful purchases through - /// ``StoreKit/Transaction/updates`` by default and don't need to call this + /// `Transaction.updates` by default and don't need to call this /// method. Use it for a result returned directly by a `Product` purchase /// API or by a custom StoreKit view completion action. public func process( @@ -136,6 +146,12 @@ where } /// Returns verified transaction history for a product in newest-first order. + /// + /// StoreKit omits finished consumables unless the app enables + /// `SKIncludeConsumableInAppPurchaseHistory` in its information property + /// list. Revoked and refunded transactions remain in the returned history. + /// Results are ordered by purchase date, signed date, and transaction + /// identifier descending, then exact JWS UTF-8 bytes ascending. public func history( for productID: Product.ID ) async throws -> [StoreTransactionSnapshot] { @@ -146,7 +162,10 @@ where /// Synchronizes App Store purchases after an explicit user restore action. /// /// This method can present authentication UI. It refreshes observable - /// entitlement state before returning. + /// entitlement state before returning. StoreKit may throw + /// ``StoreKit/StoreKitError/userCancelled`` when the user dismisses + /// authentication; treat that as a normal user outcome rather than a + /// diagnostic failure. @discardableResult public func restorePurchases() async throws -> StoreEntitlements { try await waitForStartupAttempt(operation: .restorePurchases) @@ -157,6 +176,9 @@ where /// /// Production apps normally retain the store for process lifetime. Call /// this method from controlled shutdown and test lifecycles. + /// + /// - Throws: ``StoreTransactionError/reentrantOperation(operation:)`` when + /// an injected callback attempts to close the store that is executing it. public func close() async throws { try rejectReentrancy(operation: .close) startupTask?.cancel() @@ -175,7 +197,11 @@ where ) async throws { try rejectReentrancy(operation: operation) try Task.checkCancellation() - try await startupCompletion.value() + do { + try await startupCompletion.value() + } catch is ProcessingReceiptWaiterCancellation { + throw CancellationError() + } } private func rejectReentrancy( @@ -198,12 +224,12 @@ where } @MainActor -private final class StoreOwner: Sendable +private final class TransactionStoreOwner: Sendable where EntitlementID: RawRepresentable & Hashable & Sendable, EntitlementID.RawValue == String { - weak var store: Store? + weak var store: TransactionStore? func apply(_ entitlements: StoreEntitlements) { store?.apply(entitlements) diff --git a/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift b/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift new file mode 100644 index 0000000..655cf54 --- /dev/null +++ b/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift @@ -0,0 +1,81 @@ +import Testing +@testable import StoreTransactionKit + +@Suite("Completed delivery entitlement refresh", .timeLimit(.minutes(1))) +struct CompletedDeliveryRefreshTests { + @Test("a completed redelivery retries a failed entitlement refresh") + func completedRedeliveryRetriesRefresh() async { + let snapshot = makeSnapshot( + id: 51, + productID: "lifetime.completed", + productType: .nonConsumable, + jws: "completed-redelivery" + ) + let query = FailingOnceEntitlementQuery(recovered: [snapshot]) + let handlerCalls = TestSignal() + let finishes = TestSignal() + let publications = UInt64Recorder() + let reports = StringRecorder() + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + } + let entitlements = EntitlementRefreshCoordinator( + query: { try await query.next() }, + didChange: { value in + await publications.append(UInt64(value.transactions.count)) + } + ) + let failures = FailureReporterDispatcher { failure in + await reports.append( + "\(failure.source)-\(failure.transactionID ?? 0)-\(failure.productID ?? "")" + ) + } + let pipeline = StoreTransactionPipeline( + core: core, + entitlements: entitlements, + failures: failures + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + + await pipeline.processBackground(delivery, source: .updates) + await pipeline.processBackground(delivery, source: .unfinished) + + await core.finishInputAndDrain() + await entitlements.sealAndDrain() + await failures.sealAndDrain() + #expect(await query.count() == 2) + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 1) + #expect(await publications.snapshot() == [1]) + #expect( + await reports.snapshot() == [ + "entitlementRefresh-51-lifetime.completed" + ] + ) + } +} + +private actor FailingOnceEntitlementQuery { + private let recovered: [StoreTransactionSnapshot] + private var invocationCount = 0 + + init(recovered: [StoreTransactionSnapshot]) { + self.recovered = recovered + } + + func next() throws -> [StoreTransactionSnapshot] { + invocationCount += 1 + if invocationCount == 1 { + throw TestFailure() + } + return recovered + } + + func count() -> Int { + invocationCount + } +} diff --git a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift index 37a8cf7..57ef83b 100644 --- a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift +++ b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift @@ -1,7 +1,7 @@ import Testing @testable import StoreTransactionKit -@Suite("EntitlementRefreshCoordinator") +@Suite("EntitlementRefreshCoordinator", .timeLimit(.minutes(1))) struct EntitlementRefreshCoordinatorTests { @Test("reservations that arrive during a query run in the next cycle") func cutoffReservations() async throws { @@ -15,18 +15,18 @@ struct EntitlementRefreshCoordinatorTests { ) let first = await coordinator.reserve() - await query.waitForRequest(1) + try await query.waitForRequest(1) let second = await coordinator.reserve() await query.succeed([makeSnapshot(id: 1, productID: "b")]) - let firstValue = try await first.terminalValue() + let firstValue = try await first.receipt.terminalValue() #expect(firstValue.transactions.map(\.productID) == ["b"]) - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.succeed([ makeSnapshot(id: 2, productID: "a"), makeSnapshot(id: 1, productID: "b"), ]) - let secondValue = try await second.terminalValue() + let secondValue = try await second.receipt.terminalValue() #expect(secondValue.transactions.map(\.productID) == ["a", "b"]) #expect(await publicationSizes.snapshot() == [1, 2]) @@ -46,14 +46,14 @@ struct EntitlementRefreshCoordinatorTests { let snapshot = makeSnapshot(id: 4) let first = await coordinator.reserve() - await query.waitForRequest(1) + try await query.waitForRequest(1) await query.succeed([snapshot]) - _ = try await first.terminalValue() + _ = try await first.receipt.terminalValue() let second = await coordinator.reserve() - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.succeed([snapshot]) - let value = try await second.terminalValue() + let value = try await second.receipt.terminalValue() #expect(value.transactions == [snapshot]) #expect(await publicationSizes.snapshot() == [1]) @@ -72,19 +72,45 @@ struct EntitlementRefreshCoordinatorTests { ) let failed = await coordinator.reserve() - await query.waitForRequest(1) + try await query.waitForRequest(1) await query.fail(TestFailure()) await #expect(throws: TestFailure.self) { - _ = try await failed.terminalValue() + _ = try await failed.receipt.terminalValue() } #expect(await publicationSizes.snapshot().isEmpty) let recovered = await coordinator.reserve() - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.succeed([]) - let value = try await recovered.terminalValue() + let value = try await recovered.receipt.terminalValue() #expect(value.transactions.isEmpty) #expect(await publicationSizes.snapshot() == [0]) await coordinator.sealAndDrain() } + + @Test("each pending query batch has one reporting owner") + func pendingBatchReportingAuthority() async throws { + let query = ControlledEntitlementQuery() + let coordinator = EntitlementRefreshCoordinator( + query: { try await query.next() }, + didChange: { _ in } + ) + + let active = await coordinator.reserve() + try await query.waitForRequest(1) + let nextOwner = await coordinator.reserve() + let nextObserver = await coordinator.reserve() + + #expect(active.role == .owner) + #expect(nextOwner.role == .owner) + #expect(nextObserver.role == .observer) + + await query.succeed([]) + _ = try await active.receipt.terminalValue() + try await query.waitForRequest(2) + await query.succeed([]) + _ = try await nextOwner.receipt.terminalValue() + _ = try await nextObserver.receipt.terminalValue() + await coordinator.sealAndDrain() + } } diff --git a/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift b/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift new file mode 100644 index 0000000..344166c --- /dev/null +++ b/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift @@ -0,0 +1,231 @@ +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Failure reporter dispatcher", .timeLimit(.minutes(1))) +struct FailureReporterDispatcherTests { + @Test("capacity one delivers every admitted failure before enqueue returns") + func boundedDeliveryIsLossless() async throws { + let callbackEntered = TestSignal() + let callbackGate = TestGate() + let callbackValues = UInt64Recorder() + let enqueueCompleted = TestSignal() + let dispatcher = FailureReporterDispatcher(capacity: 1) { failure in + guard let transactionID = failure.transactionID else { + Issue.record("The test failure lost its transaction identifier.") + return + } + await callbackValues.append(transactionID) + await callbackEntered.send() + _ = try? await callbackGate.wait() + } + + let first = Task { + await dispatcher.enqueue(makeFailure(id: 1)) + await enqueueCompleted.send() + } + try await callbackEntered.wait(for: 1) + #expect(await enqueueCompleted.value() == 0) + + let second = Task { + await dispatcher.enqueue(makeFailure(id: 2)) + await enqueueCompleted.send() + } + let third = Task { + await dispatcher.enqueue(makeFailure(id: 3)) + await enqueueCompleted.send() + } + + await callbackGate.open() + await first.value + await second.value + await third.value + await dispatcher.sealAndDrain() + + let values = await callbackValues.snapshot() + #expect(values.first == 1) + #expect(Set(values) == [1, 2, 3]) + #expect(await enqueueCompleted.value() == 3) + } + + @Test("seal waits for an active failure callback and its enqueue receipt") + func sealDrainsActiveCallback() async throws { + let callbackEntered = TestSignal() + let callbackGate = TestGate() + let enqueueCompleted = TestSignal() + let sealStarted = TestSignal() + let sealCompleted = TestSignal() + let dispatcher = FailureReporterDispatcher(capacity: 1) { _ in + await callbackEntered.send() + _ = try? await callbackGate.wait() + } + + let enqueue = Task { + await dispatcher.enqueue(makeFailure(id: 1)) + await enqueueCompleted.send() + } + try await callbackEntered.wait(for: 1) + + let seal = Task { + await sealStarted.send() + await dispatcher.sealAndDrain() + await sealCompleted.send() + } + try await sealStarted.wait(for: 1) + #expect(await enqueueCompleted.value() == 0) + #expect(await sealCompleted.value() == 0) + + await callbackGate.open() + await enqueue.value + await seal.value + + #expect(await enqueueCompleted.value() == 1) + #expect(await sealCompleted.value() == 1) + } + + private func makeFailure(id: UInt64) -> StoreTransactionBackgroundFailure { + StoreTransactionBackgroundFailure( + source: .updates, + transactionID: id, + productID: "product-\(id)", + underlyingError: TestFailure() + ) + } +} + +@Suite("Restore coordinator failures", .timeLimit(.minutes(1))) +struct RestoreCoordinatorFailureTests { + @Test("coalesced waiters share a failure and the next reservation retries") + func coalescedFailureAllowsRetry() async throws { + let synchronization = ControlledRestoreSynchronization() + let entitlementQueryCount = TestSignal() + let entitlements = EntitlementRefreshCoordinator( + query: { + await entitlementQueryCount.send() + return [] + }, + didChange: { _ in } + ) + let coordinator = RestoreCoordinator( + synchronize: { try await synchronization.run() }, + entitlements: entitlements + ) + + let first = await coordinator.reserve() + try await synchronization.waitForAttempt(1) + let second = await coordinator.reserve() + #expect(first.role == .owner) + #expect(second.role == .observer) + #expect(first.receipt === second.receipt) + + await synchronization.releaseFirstAttempt() + await #expect(throws: RestoreCoordinatorFailure.self) { + _ = try await first.receipt.terminalValue() + } + await #expect(throws: RestoreCoordinatorFailure.self) { + _ = try await second.receipt.terminalValue() + } + + let retry = await coordinator.reserve() + #expect(retry.role == .owner) + #expect(retry.receipt !== first.receipt) + try await synchronization.waitForAttempt(2) + let value = try await retry.receipt.terminalValue() + + #expect(value.transactions.isEmpty) + #expect(await synchronization.attemptCount() == 2) + #expect(await entitlementQueryCount.value() == 1) + await entitlements.sealAndDrain() + } +} + +@Suite("Session closing admission", .timeLimit(.minutes(1))) +struct SessionClosingAdmissionTests { + @Test("closing rejects every new session operation") + func closingRejectsNewOperations() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + fixture.unfinished.finish() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in }, + reportFailure: { _ in } + ) + + let startup = Task { try await session.start() } + try await query.waitForRequest(1) + await query.succeed([]) + _ = try await startup.value + + let acceptedRefresh = Task { + try await session.currentEntitlements() + } + try await query.waitForRequest(2) + + let close = Task { try await session.close() } + try await fixture.updateTermination.wait(for: 1) + + await expectClosing("start") { + _ = try await session.start() + } + await expectClosing("process") { + _ = try await session.process(.pending) + } + await expectClosing("currentEntitlements") { + _ = try await session.currentEntitlements() + } + await expectClosing("history") { + _ = try await session.history(for: "product") + } + await expectClosing("restorePurchases") { + _ = try await session.restorePurchases() + } + + await query.succeed([]) + _ = try await acceptedRefresh.value + try await close.value + } + + private func expectClosing( + _ operationName: String, + operation: () async throws -> Void + ) async { + do { + try await operation() + Issue.record("\(operationName) accepted new work while closing.") + } catch StoreTransactionError.closing { + } catch { + Issue.record("\(operationName) returned an unexpected error: \(error)") + } + } +} + +private actor ControlledRestoreSynchronization { + private let started = TestSignal() + private let firstAttemptGate = TestGate() + private var attempts = 0 + + func run() async throws { + attempts += 1 + let attempt = attempts + await started.send() + if attempt == 1 { + try await firstAttemptGate.wait() + throw TestFailure() + } + } + + func waitForAttempt(_ count: Int) async throws { + try await started.wait(for: count) + } + + func releaseFirstAttempt() async { + await firstAttemptGate.open() + } + + func attemptCount() -> Int { + attempts + } +} diff --git a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift new file mode 100644 index 0000000..1f0f1b7 --- /dev/null +++ b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift @@ -0,0 +1,172 @@ +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Current entitlement reconciliation fixed point", .timeLimit(.minutes(1))) +struct ReconciliationFixedPointTests { + @Test("reconciliation repeats until no new entitlement revision remains") + func repeatsUntilNoNewRevision() async throws { + let first = makeSnapshot( + id: 41, + productID: "lifetime.first", + productType: .nonConsumable, + jws: "fixed-point-first" + ) + let second = makeSnapshot( + id: 42, + productID: "lifetime.second", + productType: .nonConsumable, + jws: "fixed-point-second" + ) + let current = EntitlementValueSource([]) + let unfinished = UnfinishedValueSource() + let currentQueryCount = TestSignal() + let unfinishedQueryCount = TestSignal() + let handled = UInt64Recorder() + let finished = UInt64Recorder() + let reports = StringRecorder() + + let persistentFirst = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: first) + ) + let secondDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: second) { + await finished.append(second.id) + } + ) + let firstDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: first) { + await finished.append(first.id) + await current.replace(with: [first, second]) + await unfinished.replace( + with: [persistentFirst, secondDelivery] + ) + } + ) + await unfinished.replace(with: [firstDelivery]) + + let core = TransactionProcessingCore { snapshot in + await handled.append(snapshot.id) + } + let failures = FailureReporterDispatcher { failure in + await reports.append("\(failure.source)") + } + let reconciler = CurrentEntitlementReconciler( + query: { + await currentQueryCount.send() + return CurrentEntitlementQueryResult( + snapshots: await current.read(), + verificationFailures: [] + ) + }, + queryUnfinished: { + await unfinishedQueryCount.send() + return await unfinished.read() + }, + core: core, + failures: failures + ) + + let snapshots = try await reconciler.query() + + await core.finishInputAndDrain() + await failures.sealAndDrain() + #expect(snapshots == [first, second]) + #expect(await handled.snapshot() == [first.id, second.id]) + #expect(await finished.snapshot() == [first.id, second.id]) + #expect(await currentQueryCount.value() == 3) + #expect(await unfinishedQueryCount.value() == 3) + #expect(await reports.snapshot().isEmpty) + } + + @Test("only the stable query reports current entitlement verification failures") + func reportsStableVerificationFailuresOnce() async throws { + let snapshot = makeSnapshot( + id: 43, + productID: "lifetime.verified", + productType: .nonConsumable, + jws: "fixed-point-verification" + ) + let current = EntitlementValueSource([]) + let unfinished = UnfinishedValueSource() + let currentQueryCount = TestSignal() + let unfinishedQueryCount = TestSignal() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + + let persistentDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) + ) + let initialDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await current.replace(with: [snapshot]) + await unfinished.replace(with: [persistentDelivery]) + await finishes.send() + } + ) + await unfinished.replace(with: [initialDelivery]) + + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + } + let failures = FailureReporterDispatcher { failure in + guard failure.source == .currentEntitlementVerification, + let verificationFailure = + failure.underlyingError as? StoreTransactionVerificationError + else { + await reports.append("unexpected") + return + } + switch verificationFailure.underlyingError { + case is DiscardedVerificationFailure: + await reports.append("discarded") + case is StableVerificationFailure: + await reports.append("stable") + default: + await reports.append("unexpected") + } + } + let reconciler = CurrentEntitlementReconciler( + query: { + await currentQueryCount.send() + let queryNumber = await currentQueryCount.value() + let underlyingError: any Error = + if queryNumber == 1 { + DiscardedVerificationFailure() + } else { + StableVerificationFailure() + } + return CurrentEntitlementQueryResult( + snapshots: await current.read(), + verificationFailures: [ + StoreTransactionVerificationError( + underlyingError: underlyingError + ) + ] + ) + }, + queryUnfinished: { + await unfinishedQueryCount.send() + return await unfinished.read() + }, + core: core, + failures: failures + ) + + let snapshots = try await reconciler.query() + + await core.finishInputAndDrain() + await failures.sealAndDrain() + #expect(snapshots == [snapshot]) + #expect(await currentQueryCount.value() == 2) + #expect(await unfinishedQueryCount.value() == 2) + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["stable"]) + } +} + +private struct DiscardedVerificationFailure: Error, Sendable {} + +private struct StableVerificationFailure: Error, Sendable {} diff --git a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift index 354f9b5..8ae50c8 100644 --- a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift @@ -3,8 +3,44 @@ import StoreKit import Testing @testable import StoreTransactionKit -@Suite("Runtime contracts") +@Suite("Runtime contracts", .timeLimit(.minutes(1))) struct RuntimeContractTests { + @Test("receipt waiter cancellation is distinct from terminal cancellation failure") + func receiptCancellationIdentity() async throws { + let terminalFailure = ProcessingReceipt() + terminalFailure.fail(CancellationError()) + + do { + try await terminalFailure.value() + Issue.record("A terminal cancellation failure unexpectedly succeeded.") + } catch is ProcessingReceiptWaiterCancellation { + Issue.record("A terminal failure was mistaken for waiter cancellation.") + } catch is CancellationError { + // The dependency's terminal failure remains intact. + } catch { + Issue.record("Unexpected terminal receipt error: \(error)") + } + + let pending = ProcessingReceipt() + let gate = TestGate() + let waiter = Task { + _ = try? await gate.wait() + do { + try await pending.value() + return false + } catch is ProcessingReceiptWaiterCancellation { + return true + } catch { + Issue.record("Unexpected cancelled waiter error: \(error)") + return false + } + } + waiter.cancel() + await gate.open() + + #expect(await waiter.value) + } + @Test("immediate purchase outcomes honor caller cancellation") func immediatePurchaseOutcomeCancellation() async throws { let fixture = TestSourceFixture() @@ -19,7 +55,7 @@ struct RuntimeContractTests { for result: Product.PurchaseResult in [.pending, .userCancelled] { let gate = TestGate() let process = Task { - await gate.wait() + _ = try? await gate.wait() return try await session.process(result) } process.cancel() @@ -37,33 +73,145 @@ struct RuntimeContractTests { func restoreCoalescing() async throws { let synchronizationStarted = TestSignal() let synchronizationGate = TestGate() + let entitlementQueryCount = TestSignal() + let entitlements = EntitlementRefreshCoordinator( + query: { + await entitlementQueryCount.send() + return [] + }, + didChange: { _ in } + ) + let coordinator = RestoreCoordinator( + synchronize: { + await synchronizationStarted.send() + try await synchronizationGate.wait() + }, + entitlements: entitlements + ) + + let first = await coordinator.reserve() + try await synchronizationStarted.wait(for: 1) + let second = await coordinator.reserve() + + #expect(first.role == .owner) + #expect(second.role == .observer) + #expect(first.receipt === second.receipt) + + await synchronizationGate.open() + let firstValue = try await first.receipt.terminalValue() + let secondValue = try await second.receipt.terminalValue() + + #expect(firstValue == secondValue) + #expect(await synchronizationStarted.value() == 1) + #expect(await entitlementQueryCount.value() == 1) + await entitlements.sealAndDrain() + } + + @Test("a cancelled restore observer does not report an attached failure") + func cancelledRestoreObserverDoesNotReportAttachedFailure() async throws { + let synchronizationStarted = TestSignal() + let synchronizationGate = TestGate() + let reports = StringRecorder() let fixture = TestSourceFixture( synchronize: { await synchronizationStarted.send() - await synchronizationGate.wait() + try await synchronizationGate.wait() + throw TestFailure() } ) fixture.unfinished.finish() - let session = StoreTransactionSession( + let runtime = StoreTransactionRuntime( + sessionID: UUID(), source: fixture.source, handleTransaction: { _ in }, - reportFailure: { _ in } + entitlementsDidChange: { _ in }, + reportFailure: { failure in + switch failure.source { + case .abandonedDirectOperation(.restorePurchases): + await reports.append("restore") + default: + await reports.append("unexpected") + } + } ) - _ = try await session.start() + _ = try await runtime.readiness() - async let first = session.restorePurchases() - await synchronizationStarted.wait(for: 1) - async let second = session.restorePurchases() - await Task.yield() - #expect(await synchronizationStarted.value() == 1) + let ownerLeases = try #require(runtime.beginOperation()) + let owner = Task { + try await runtime.restorePurchases(leases: ownerLeases) + } + try await synchronizationStarted.wait(for: 1) + + let observerLeases = try #require(runtime.beginOperation()) + let observer = Task { + try await runtime.restorePurchases(leases: observerLeases) + } + observer.cancel() + await #expect(throws: CancellationError.self) { + _ = try await observer.value + } await synchronizationGate.open() - let (firstValue, secondValue) = try await (first, second) + await #expect(throws: TestFailure.self) { + _ = try await owner.value + } + await runtime.close() - #expect(firstValue == secondValue) - #expect(await synchronizationStarted.value() == 1) - #expect(await fixture.entitlementQueryCount.value() == 2) - try await session.close() + #expect(await reports.snapshot().isEmpty) + } + + @Test("cancelled coalesced restore callers report one physical failure") + func cancelledRestoreCallersReportOnce() async throws { + let synchronizationStarted = TestSignal() + let synchronizationGate = TestGate() + let reports = StringRecorder() + let fixture = TestSourceFixture( + synchronize: { + await synchronizationStarted.send() + try await synchronizationGate.wait() + throw TestFailure() + } + ) + fixture.unfinished.finish() + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + handleTransaction: { _ in }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + switch failure.source { + case .abandonedDirectOperation(.restorePurchases): + await reports.append("restore") + default: + await reports.append("unexpected") + } + } + ) + _ = try await runtime.readiness() + + let ownerLeases = try #require(runtime.beginOperation()) + let owner = Task { + try await runtime.restorePurchases(leases: ownerLeases) + } + try await synchronizationStarted.wait(for: 1) + owner.cancel() + await #expect(throws: CancellationError.self) { + _ = try await owner.value + } + + let observerLeases = try #require(runtime.beginOperation()) + let observer = Task { + try await runtime.restorePurchases(leases: observerLeases) + } + observer.cancel() + await #expect(throws: CancellationError.self) { + _ = try await observer.value + } + + await synchronizationGate.open() + await runtime.close() + + #expect(await reports.snapshot() == ["restore"]) } @Test("an abandoned refresh reports its later failure exactly once") @@ -90,18 +238,18 @@ struct RuntimeContractTests { ) let startup = Task { try await session.start() } - await query.waitForRequest(1) + try await query.waitForRequest(1) await query.succeed([]) _ = try await startup.value let refresh = Task { try await session.currentEntitlements() } - await query.waitForRequest(2) + try await query.waitForRequest(2) refresh.cancel() await #expect(throws: CancellationError.self) { _ = try await refresh.value } await query.fail(TestFailure()) - await reported.wait(for: 1) + try await reported.wait(for: 1) try await session.close() #expect(await reports.snapshot() == ["abandoned-refresh"]) @@ -168,21 +316,314 @@ struct RuntimeContractTests { ) let startup = Task { try await session.start() } - await query.waitForRequest(1) + try await query.waitForRequest(1) await query.succeed([]) _ = try await startup.value fixture.updates.yield( .verified(makeEnvelope(snapshot: makeSnapshot(id: 19))) ) - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.fail(TestFailure()) - await reported.wait(for: 1) + try await reported.wait(for: 1) try await session.close() #expect(await reports.snapshot() == ["entitlementRefresh-19-product"]) } + @Test("reconciliation requeries after handling a newly unfinished revision") + func reconciliationRequeriesAfterUnfinishedHandling() async throws { + let entitlementQueryCount = TestSignal() + let unfinishedQueryStarted = TestSignal() + let unfinishedQueryGate = TestGate() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let currentEntitlements = EntitlementValueSource([]) + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 24, + productType: .nonConsumable + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "arrived-after-query") { + await currentEntitlements.replace(with: [snapshot]) + await finishes.send() + } + ) + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + } + let failures = FailureReporterDispatcher { failure in + await reports.append("\(failure.source)") + } + let reconciler = CurrentEntitlementReconciler( + query: { + await entitlementQueryCount.send() + return CurrentEntitlementQueryResult( + snapshots: await currentEntitlements.read(), + verificationFailures: [] + ) + }, + queryUnfinished: { + await unfinishedQueryStarted.send() + _ = try? await unfinishedQueryGate.wait() + return [delivery] + }, + core: core, + failures: failures + ) + + let query = Task { try await reconciler.query() } + try await unfinishedQueryStarted.wait(for: 1) + #expect(await entitlementQueryCount.value() == 1) + + await unfinishedQueryGate.open() + let snapshots = try await query.value + + #expect(snapshots == [snapshot]) + #expect(await entitlementQueryCount.value() == 2) + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 1) + await core.finishInputAndDrain() + await failures.sealAndDrain() + #expect(await reports.snapshot().isEmpty) + } + + @Test("duplicate background deliveries report one handler failure") + func duplicateBackgroundDeliveryFailure() async throws { + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + await handlerStarted.send() + try await handlerGate.wait() + throw TestFailure() + } + let entitlements = EntitlementRefreshCoordinator( + query: { + Issue.record("A failed transaction unexpectedly refreshed entitlements.") + return [] + }, + didChange: { _ in } + ) + let failures = FailureReporterDispatcher { failure in + switch failure.source { + case .updates: + await reports.append("updates") + case .unfinished: + await reports.append("unfinished") + default: + await reports.append("unexpected") + } + } + let pipeline = StoreTransactionPipeline( + core: core, + entitlements: entitlements, + failures: failures + ) + let snapshot = makeSnapshot(id: 21) + let update = try await pipeline.accept( + .verified( + makeEnvelope(snapshot: snapshot, revision: "same") { + await finishes.send() + }) + ) + try await handlerStarted.wait(for: 1) + let unfinished = try await pipeline.accept( + .verified( + makeEnvelope(snapshot: snapshot, revision: "same") { + await finishes.send() + }) + ) + + #expect(update.acceptance.role == .owner) + #expect(unfinished.acceptance.role == .inFlightObserver) + + let updateTask = Task { + await pipeline.processAcceptedBackground( + snapshot: update.snapshot, + acceptance: update.acceptance, + source: .updates + ) + } + let unfinishedTask = Task { + await pipeline.processAcceptedBackground( + snapshot: unfinished.snapshot, + acceptance: unfinished.acceptance, + source: .unfinished + ) + } + await handlerGate.open() + await updateTask.value + await unfinishedTask.value + + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 0) + #expect(await reports.snapshot() == ["updates"]) + + await core.finishInputAndDrain() + await entitlements.sealAndDrain() + await failures.sealAndDrain() + } + + @Test("a later delivery retries after an earlier handler attempt fails") + func laterDeliveryRetriesFailedRevision() async { + let handlerCalls = TestSignal() + let reports = StringRecorder() + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + throw TestFailure() + } + let entitlements = EntitlementRefreshCoordinator( + query: { + Issue.record("A failed transaction unexpectedly refreshed entitlements.") + return [] + }, + didChange: { _ in } + ) + let failures = FailureReporterDispatcher { failure in + await reports.append("\(failure.source)") + } + let pipeline = StoreTransactionPipeline( + core: core, + entitlements: entitlements, + failures: failures + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: makeSnapshot(id: 22), revision: "retry") + ) + + await pipeline.processBackground(delivery, source: .updates) + await pipeline.processBackground(delivery, source: .unfinished) + + #expect(await handlerCalls.value() == 2) + #expect(await reports.snapshot() == ["updates", "unfinished"]) + await core.finishInputAndDrain() + await entitlements.sealAndDrain() + await failures.sealAndDrain() + } + + @Test("a cancelled direct observer leaves failure reporting with the background owner") + func directObserverCancellationDoesNotDuplicateFailure() async throws { + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let handlerCalls = TestSignal() + let reported = TestSignal() + let reports = StringRecorder() + let fixture = TestSourceFixture() + fixture.unfinished.finish() + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + await handlerStarted.send() + try await handlerGate.wait() + throw TestFailure() + }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + await reports.append("\(failure.source)") + await reported.send() + } + ) + _ = try await runtime.readiness() + let snapshot = makeSnapshot(id: 23) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "shared") + ) + fixture.updates.yield(delivery) + try await handlerStarted.wait(for: 1) + + let leases = try #require(runtime.beginOperation()) + let directObserver = Task { + try await runtime.process(delivery, leases: leases) + } + directObserver.cancel() + await #expect(throws: CancellationError.self) { + _ = try await directObserver.value + } + + await handlerGate.open() + try await reported.wait(for: 1) + await runtime.close() + + #expect(await handlerCalls.value() == 1) + #expect(await reports.snapshot() == ["updates"]) + } + + @Test("a cancelled completed observer reports its own refresh failure") + func completedObserverCancellationReportsRefreshFailure() async throws { + let query = ControlledEntitlementQuery() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reported = TestSignal() + let reports = StringRecorder() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + fixture.unfinished.finish() + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + switch failure.source { + case .abandonedDirectOperation(.processPurchase): + await reports.append("abandoned-process") + default: + await reports.append("unexpected") + } + await reported.send() + } + ) + + let readiness = Task { try await runtime.readiness() } + try await query.waitForRequest(1) + await query.succeed([]) + _ = try await readiness.value + + let snapshot = makeSnapshot( + id: 25, + productType: .nonConsumable + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot, revision: "completed") { + await finishes.send() + } + ) + let firstLeases = try #require(runtime.beginOperation()) + let firstProcess = Task { + try await runtime.process(delivery, leases: firstLeases) + } + try await query.waitForRequest(2) + await query.succeed([snapshot]) + _ = try await firstProcess.value + + let secondLeases = try #require(runtime.beginOperation()) + let completedObserver = Task { + try await runtime.process(delivery, leases: secondLeases) + } + try await query.waitForRequest(3) + completedObserver.cancel() + await #expect(throws: CancellationError.self) { + _ = try await completedObserver.value + } + await query.fail(TestFailure()) + try await reported.wait(for: 1) + + await runtime.close() + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["abandoned-process"]) + } + @Test("close completes after accepted handling and finish") func closeDrainsAcceptedTransaction() async throws { let handlerStarted = TestSignal() @@ -197,7 +638,7 @@ struct RuntimeContractTests { handleTransaction: { _ in await events.append("handle-start") await handlerStarted.send() - await handlerGate.wait() + try await handlerGate.wait() await events.append("handle-end") }, reportFailure: { _ in } @@ -208,7 +649,7 @@ struct RuntimeContractTests { makeEnvelope(snapshot: makeSnapshot(id: 20)) { await events.append("finish") })) - await handlerStarted.wait(for: 1) + try await handlerStarted.wait(for: 1) let firstClose = Task { await closeCallersStarted.send() @@ -222,7 +663,7 @@ struct RuntimeContractTests { await events.append("close-2") await closeCallersFinished.send() } - await closeCallersStarted.wait(for: 2) + try await closeCallersStarted.wait(for: 2) #expect(await closeCallersFinished.value() == 0) await handlerGate.open() @@ -254,10 +695,10 @@ struct CompletedRevisionCacheTests { } } -@Suite("Task completion bag") +@Suite("Task completion bag", .timeLimit(.minutes(1))) struct TaskCompletionBagTests { - @Test("completed tasks are released before shutdown") - func completedTasksAreReleased() async { + @Test("completed tasks are released when the bag becomes empty") + func completedTasksAreReleased() async throws { let bag = TaskCompletionBag() let completed = TestSignal() @@ -267,11 +708,8 @@ struct TaskCompletionBagTests { await completed.send() }) } - await completed.wait(for: 32) - - for _ in 0..<100 where bag.retainedTaskCount() != 0 { - await Task.yield() - } + try await completed.wait(for: 32) + await bag.waitForAll() #expect(bag.retainedTaskCount() == 0) } } diff --git a/Tests/StoreTransactionKitTests/StoreTests.swift b/Tests/StoreTransactionKitTests/StoreTests.swift index d8a45e0..6b6850b 100644 --- a/Tests/StoreTransactionKitTests/StoreTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTests.swift @@ -1,7 +1,7 @@ import Testing @testable import StoreTransactionKit -@Suite("Observable Store") +@Suite("Observable TransactionStore", .timeLimit(.minutes(1))) @MainActor struct StoreTests { private enum SubscriptionID: String, Hashable, Sendable { @@ -19,7 +19,7 @@ struct StoreTests { currentEntitlements: { await values.read() } ) fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, reportFailure: { _ in } @@ -45,6 +45,42 @@ struct StoreTests { try await store.close() } + @Test("an upgraded transaction remains in the snapshot without granting access") + func upgradedTransactionProjection() async throws { + let fixture = TestSourceFixture( + currentEntitlements: { + [ + makeSnapshot( + id: 1, + productID: SubscriptionID.monthly.rawValue, + isUpgraded: true + ), + makeSnapshot( + id: 2, + productID: SubscriptionID.yearly.rawValue + ), + ] + } + ) + fixture.unfinished.finish() + let store = TransactionStore( + source: fixture.source, + handleTransaction: { _ in }, + reportFailure: { _ in } + ) + + await store.waitForStartup() + + #expect(store.activeEntitlements == [.yearly]) + #expect( + store.entitlements?.transactions.map(\.productID) == [ + SubscriptionID.monthly.rawValue, + SubscriptionID.yearly.rawValue, + ] + ) + try await store.close() + } + @Test("a later refresh recovers observable state after startup failure") func startupFailureRecovery() async throws { let query = ControlledEntitlementQuery() @@ -52,14 +88,14 @@ struct StoreTests { currentEntitlements: { try await query.next() } ) fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, reportFailure: { _ in } ) #expect(store.activeEntitlements == nil) - await query.waitForRequest(1) + try await query.waitForRequest(1) await query.fail(TestFailure()) await store.waitForStartup() #expect(store.entitlements == nil) @@ -67,7 +103,7 @@ struct StoreTests { #expect(store.startupError != nil) let refresh = Task { try await store.refreshEntitlements() } - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.succeed([ makeSnapshot(id: 4, productID: SubscriptionID.yearly.rawValue) ]) @@ -78,11 +114,64 @@ struct StoreTests { try await store.close() } + @Test("a stale startup failure cannot replace newer entitlement readiness") + func newerReadinessWinsStartupFailureRace() async throws { + let snapshot = makeSnapshot( + id: 7, + productID: SubscriptionID.monthly.rawValue + ) + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let store = TransactionStore( + source: fixture.source, + handleTransaction: { _ in }, + reportFailure: { failure in + Issue.record("Unexpected background failure: \(failure)") + } + ) + + fixture.updates.yield( + .verified(makeEnvelope(snapshot: snapshot)) + ) + try await query.waitForRequest(1) + await query.succeed([snapshot]) + + fixture.unfinished.finish() + try await query.waitForRequest(2) + await query.fail(TestFailure()) + await store.waitForStartup() + + #expect(store.activeEntitlements == [.monthly]) + #expect(store.startupError == nil) + try await store.close() + } + + @Test("a dependency cancellation failure remains a startup error") + func startupCancellationFailure() async throws { + let fixture = TestSourceFixture( + currentEntitlements: { throw CancellationError() } + ) + fixture.unfinished.finish() + let store = TransactionStore( + source: fixture.source, + handleTransaction: { _ in }, + reportFailure: { _ in } + ) + + await store.waitForStartup() + + #expect(store.startupError is CancellationError) + #expect(store.activeEntitlements == nil) + try await store.close() + } + @Test("an empty set means entitlement resolution completed") func emptyTypedEntitlementProjection() async throws { let fixture = TestSourceFixture(currentEntitlements: { [] }) fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, reportFailure: { _ in } @@ -94,10 +183,10 @@ struct StoreTests { try await store.close() } - @Test("the Store facade rejects handler reentry during startup") + @Test("the TransactionStore facade rejects handler reentry during startup") func startupHandlerReentrancy() async throws { let fixture = TestSourceFixture() - let holder = StoreHolder() + let holder = TransactionStoreHolder() let rejected = TestSignal() let finished = TestSignal() fixture.unfinished.yield( @@ -107,18 +196,18 @@ struct StoreTests { })) fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in do { _ = try await holder.get().refreshEntitlements() - Issue.record("Store unexpectedly allowed handler reentry.") + Issue.record("TransactionStore unexpectedly allowed handler reentry.") } catch StoreTransactionError.reentrantOperation( operation: .currentEntitlements ) { await rejected.send() } catch { - Issue.record("Unexpected Store reentrancy error: \(error)") + Issue.record("Unexpected TransactionStore reentrancy error: \(error)") } }, reportFailure: { _ in } @@ -139,12 +228,12 @@ struct StoreTests { currentEntitlements: { try await query.next() } ) fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, reportFailure: { _ in } ) - await query.waitForRequest(1) + try await query.waitForRequest(1) let refresh = Task { try await store.refreshEntitlements() } refresh.cancel() @@ -164,7 +253,7 @@ struct StoreTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - let holder = StoreHolder() + let holder = TransactionStoreHolder() let closeRejected = TestSignal() let startupCompleted = TestSignal() fixture.unfinished.yield( @@ -172,18 +261,18 @@ struct StoreTests { ) fixture.unfinished.finish() - let store = Store( + let store = TransactionStore( source: fixture.source, handleTransaction: { _ in do { try await holder.get().close() - Issue.record("Store unexpectedly allowed a reentrant close.") + Issue.record("TransactionStore unexpectedly allowed a reentrant close.") } catch StoreTransactionError.reentrantOperation( operation: .close ) { await closeRejected.send() } catch { - Issue.record("Unexpected Store reentrancy error: \(error)") + Issue.record("Unexpected TransactionStore reentrancy error: \(error)") } }, reportFailure: { _ in } @@ -194,12 +283,12 @@ struct StoreTests { await startupCompleted.send() } - await closeRejected.wait(for: 1) - await query.waitForRequest(1) + try await closeRejected.wait(for: 1) + try await query.waitForRequest(1) #expect(await startupCompleted.value() == 0) await query.succeed([]) - await query.waitForRequest(2) + try await query.waitForRequest(2) await query.succeed([]) await startupWaiter.value #expect(await startupCompleted.value() == 1) diff --git a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift index 9e10194..efa0f0e 100644 --- a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import StoreTransactionKit -@Suite("StoreTransactionSession") +@Suite("StoreTransactionSession", .timeLimit(.minutes(1))) struct StoreTransactionSessionTests { @Test("start publishes initial entitlements and close terminates producers") func startAndClose() async throws { @@ -23,7 +23,420 @@ struct StoreTransactionSessionTests { #expect(await publicationSizes.snapshot() == [0]) try await session.close() - await fixture.updateTermination.wait(for: 1) + try await fixture.updateTermination.wait(for: 1) + try await fixture.subscriptionStatusTermination.wait(for: 1) + } + + @Test("initial entitlement publication joins unfinished processing") + func initialEntitlementsJoinUnfinishedProcessing() async throws { + let snapshot = makeSnapshot(id: 41, productID: "subscription.plus") + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let events = StringRecorder() + let publications = UInt64Recorder() + let fixture = TestSourceFixture( + currentEntitlements: { [snapshot] }, + queryUnfinished: { + [ + .verified( + makeEnvelope(snapshot: snapshot) { + await events.append("finish-41") + }) + ] + } + ) + fixture.unfinished.finish() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { transaction in + await events.append("handle-\(transaction.id)") + await handlerStarted.send() + try await handlerGate.wait() + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + }, + reportFailure: { failure in + Issue.record("Unexpected startup failure: \(failure)") + } + ) + + let startup = Task { try await session.start() } + try await handlerStarted.wait(for: 1) + #expect(await publications.snapshot().isEmpty) + + await handlerGate.open() + let readiness = try await startup.value + + #expect(readiness.entitlements.transactions == [snapshot]) + #expect(await events.snapshot() == ["handle-41", "finish-41"]) + #expect(await publications.snapshot() == [1]) + try await session.close() + } + + @Test("subscription status waits for initial entitlement readiness") + func subscriptionStatusWaitsForReadiness() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + fixture.unfinished.finish() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + Issue.record("Unexpected status failure: \(failure)") + } + ) + + let startup = Task { try await session.start() } + try await query.waitForRequest(1) + fixture.subscriptionStatusUpdates.yield() + try await fixture.subscriptionStatusDeliveryCount.wait(for: 1) + #expect(await fixture.entitlementQueryCount.value() == 1) + + await query.succeed([]) + _ = try await startup.value + try await query.waitForRequest(2) + await query.succeed([]) + + try await session.close() + } + + @Test("close cancels a status waiter but drains startup readiness") + func closeDuringSubscriptionStatusReadiness() async throws { + let query = ControlledEntitlementQuery() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + fixture.unfinished.finish() + let closeCompleted = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in }, + entitlementsDidChange: { _ in }, + reportFailure: { _ in } + ) + + let startup = Task { try await session.start() } + try await query.waitForRequest(1) + fixture.subscriptionStatusUpdates.yield() + try await fixture.subscriptionStatusDeliveryCount.wait(for: 1) + + let close = Task { + try await session.close() + await closeCompleted.send() + } + try await fixture.subscriptionStatusTermination.wait(for: 1) + #expect(await closeCompleted.value() == 0) + + await query.succeed([]) + do { + _ = try await startup.value + Issue.record("Startup unexpectedly completed after close began.") + } catch StoreTransactionError.closing { + // Closing owns the runtime after readiness drains. + } + try await close.value + #expect(await closeCompleted.value() == 1) + } + + @Test("known subscription status changes refresh without replaying handling") + func knownSubscriptionStatusReconciliation() async throws { + let snapshot = makeSnapshot(id: 1, productID: "subscription.plus") + let values = EntitlementValueSource([snapshot]) + let fixture = TestSourceFixture( + currentEntitlements: { await values.read() } + ) + fixture.unfinished.finish() + let handlerCalls = TestSignal() + let publications = UInt64Recorder() + let published = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + await published.send() + }, + reportFailure: { failure in + Issue.record("Unexpected background failure: \(failure)") + } + ) + + _ = try await session.start() + await values.replace(with: []) + + fixture.subscriptionStatusUpdates.yield() + try await published.wait(for: 2) + + #expect(await handlerCalls.value() == 0) + #expect(await fixture.entitlementQueryCount.value() == 2) + #expect(await publications.snapshot() == [1, 0]) + try await session.close() + } + + @Test("a new subscription status is handled and finished before publication") + func newSubscriptionStatusUsesTransactionPipeline() async throws { + let snapshot = makeSnapshot(id: 2, productID: "subscription.pro") + let values = EntitlementValueSource([]) + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + currentEntitlements: { await values.read() }, + queryUnfinished: { await unfinished.read() } + ) + fixture.unfinished.finish() + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let events = StringRecorder() + let publications = UInt64Recorder() + let published = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { snapshot in + await events.append("handle-\(snapshot.id)") + await handlerStarted.send() + try await handlerGate.wait() + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + await published.send() + }, + reportFailure: { failure in + Issue.record("Unexpected background failure: \(failure)") + } + ) + + _ = try await session.start() + await values.replace(with: [snapshot]) + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: snapshot) { + await events.append("finish-2") + }) + ] + ) + fixture.subscriptionStatusUpdates.yield() + + try await handlerStarted.wait(for: 1) + #expect(await publications.snapshot() == [0]) + #expect(await fixture.entitlementQueryCount.value() == 2) + + await handlerGate.open() + try await published.wait(for: 2) + #expect(await events.snapshot() == ["handle-2", "finish-2"]) + #expect(await fixture.entitlementQueryCount.value() == 3) + #expect(await publications.snapshot() == [0, 1]) + try await session.close() + } + + @Test("unrelated unfinished work does not block entitlement removal") + func unrelatedUnfinishedDoesNotBlockRemoval() async throws { + let snapshot = makeSnapshot(id: 3, productID: "subscription.plus") + let values = EntitlementValueSource([snapshot]) + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + currentEntitlements: { await values.read() }, + queryUnfinished: { await unfinished.read() } + ) + fixture.unfinished.finish() + let handlerCalls = TestSignal() + let publications = UInt64Recorder() + let published = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + throw TestFailure() + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + await published.send() + }, + reportFailure: { failure in + Issue.record("Unexpected background failure: \(failure)") + } + ) + + _ = try await session.start() + await values.replace(with: []) + await unfinished.replace( + with: [ + .verified( + makeEnvelope( + snapshot: makeSnapshot( + id: 4, + productID: "consumable.tokens" + ) + ) + ) + ] + ) + + fixture.subscriptionStatusUpdates.yield() + try await published.wait(for: 2) + + #expect(await handlerCalls.value() == 0) + #expect(await publications.snapshot() == [1, 0]) + try await session.close() + } + + @Test("revocation handling finishes before entitlement removal is published") + func revocationPrecedesRemovalPublication() async throws { + let active = makeSnapshot( + id: 31, + productID: "subscription.plus", + productType: .autoRenewable, + jws: "active-31" + ) + let revoked = makeSnapshot( + id: 31, + productID: "subscription.plus", + productType: .autoRenewable, + signedDate: Date(timeIntervalSince1970: 100), + jws: "revoked-31", + revocationDate: Date(timeIntervalSince1970: 99) + ) + let values = EntitlementValueSource([active]) + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + currentEntitlements: { await values.read() }, + queryUnfinished: { await unfinished.read() } + ) + fixture.unfinished.finish() + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let events = StringRecorder() + let publications = UInt64Recorder() + let published = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { snapshot in + #expect(snapshot.revocationDate != nil) + await events.append("handle-\(snapshot.id)") + await handlerStarted.send() + try await handlerGate.wait() + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + await published.send() + }, + reportFailure: { failure in + Issue.record("Unexpected revocation failure: \(failure)") + } + ) + + _ = try await session.start() + await values.replace(with: []) + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: revoked) { + await events.append("finish-31") + }) + ] + ) + fixture.subscriptionStatusUpdates.yield() + + try await handlerStarted.wait(for: 1) + #expect(await publications.snapshot() == [1]) + + await handlerGate.open() + try await published.wait(for: 2) + #expect(await events.snapshot() == ["handle-31", "finish-31"]) + #expect(await publications.snapshot() == [1, 0]) + try await session.close() + } + + @Test("reconciliation drains and reports every accepted handler failure") + func reconciliationDrainsAllAcceptedFailures() async throws { + let unfinished = UnfinishedValueSource([ + .verified( + makeEnvelope( + snapshot: makeSnapshot( + id: 32, + productID: "subscription.plus", + productType: .autoRenewable + ) + ) + ), + .verified( + makeEnvelope( + snapshot: makeSnapshot( + id: 33, + productID: "lifetime", + productType: .nonConsumable + ) + ) + ), + ]) + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() } + ) + fixture.unfinished.finish() + let handlerCalls = TestSignal() + let reports = UInt64Recorder() + let reported = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + throw TestFailure() + }, + reportFailure: { failure in + if failure.source == .unfinished, + let transactionID = failure.transactionID + { + await reports.append(transactionID) + await reported.send() + } + } + ) + + await #expect(throws: TestFailure.self) { + _ = try await session.start() + } + try await reported.wait(for: 2) + + #expect(await handlerCalls.value() == 2) + #expect(await reports.snapshot() == [32, 33]) + try await session.close() + } + + @Test("unverified current elements are reported without hiding verified entitlements") + func mixedCurrentEntitlementVerification() async throws { + let snapshot = makeSnapshot(id: 5, productID: "subscription.plus") + let verificationFailure = StoreTransactionVerificationError( + underlyingError: TestFailure() + ) + let fixture = TestSourceFixture( + currentEntitlements: { [snapshot] }, + currentEntitlementVerificationFailures: { + [verificationFailure] + } + ) + fixture.unfinished.finish() + let reported = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + if case .currentEntitlementVerification = failure.source { + await reported.send() + } + } + ) + + let readiness = try await session.start() + + #expect(readiness.entitlements.transactions == [snapshot]) + try await reported.wait(for: 1) + try await session.close() } @Test("updates use the durable handler then finish and refresh") @@ -51,10 +464,10 @@ struct StoreTransactionSessionTests { await events.append("finish-10") await finished.send() })) - await finished.wait(for: 1) + try await finished.wait(for: 1) #expect(await events.snapshot() == ["handle-10", "finish-10"]) - await fixture.entitlementQueryCount.wait(for: 2) + try await fixture.entitlementQueryCount.wait(for: 2) try await session.close() } @@ -83,7 +496,7 @@ struct StoreTransactionSessionTests { await finished.send() })) - await reported.wait(for: 1) + try await reported.wait(for: 1) #expect(await finished.value() == 0) try await session.close() } @@ -160,9 +573,9 @@ struct StoreTransactionSessionTests { makeEnvelope(snapshot: makeSnapshot(id: 12)) { await finished.send() })) - await finished.wait(for: 1) + try await finished.wait(for: 1) fixture.updates.yield(.unverified(TestFailure())) - await failureReported.wait(for: 1) + try await failureReported.wait(for: 1) #expect( await observations.snapshot() == [ @@ -199,7 +612,7 @@ struct StoreTransactionSessionTests { ) _ = try await session.start() - await callbackCompleted.wait(for: 1) + try await callbackCompleted.wait(for: 1) try await session.close() } } diff --git a/Tests/StoreTransactionKitTests/TestSupport.swift b/Tests/StoreTransactionKitTests/TestSupport.swift index c853e18..7ecc30f 100644 --- a/Tests/StoreTransactionKitTests/TestSupport.swift +++ b/Tests/StoreTransactionKitTests/TestSupport.swift @@ -5,49 +5,90 @@ import Testing @testable import StoreTransactionKit actor TestSignal { + private struct Waiter { + let target: Int + let continuation: CheckedContinuation + } + private var count = 0 - private var waiters: [(target: Int, continuation: CheckedContinuation)] = [] + private var waiters: [UUID: Waiter] = [:] func send() { count += 1 - let ready = waiters.filter { $0.target <= count } - waiters.removeAll { $0.target <= count } - for waiter in ready { - waiter.continuation.resume() + let ready = waiters.compactMap { id, waiter in + waiter.target <= count ? id : nil + } + for id in ready { + waiters.removeValue(forKey: id)?.continuation.resume() } } - func wait(for target: Int) async { + func wait(for target: Int) async throws { guard count < target else { return } - await withCheckedContinuation { continuation in - waiters.append((target, continuation)) + try Task.checkCancellation() + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if count >= target { + continuation.resume() + } else { + waiters[id] = Waiter( + target: target, + continuation: continuation + ) + } + } + } onCancel: { + Task { await self.cancelWaiter(id) } } } func value() -> Int { count } + + private func cancelWaiter(_ id: UUID) { + waiters.removeValue(forKey: id)?.continuation.resume( + throwing: CancellationError() + ) + } } actor TestGate { private var isOpen = false - private var waiters: [CheckedContinuation] = [] + private var waiters: [UUID: CheckedContinuation] = [:] - func wait() async { + func wait() async throws { guard !isOpen else { return } - await withCheckedContinuation { continuation in - waiters.append(continuation) + try Task.checkCancellation() + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if isOpen { + continuation.resume() + } else { + waiters[id] = continuation + } + } + } onCancel: { + Task { await self.cancelWaiter(id) } } } func open() { isOpen = true - let pending = waiters + let pending = Array(waiters.values) waiters.removeAll(keepingCapacity: false) for waiter in pending { waiter.resume() } } + + private func cancelWaiter(_ id: UUID) { + waiters.removeValue(forKey: id)?.resume( + throwing: CancellationError() + ) + } } actor StringRecorder { @@ -94,24 +135,24 @@ final class SessionHolder: Sendable { } } -final class StoreHolder: Sendable +final class TransactionStoreHolder: Sendable where EntitlementID: RawRepresentable & Hashable & Sendable, EntitlementID.RawValue == String { - private let storage = Mutex?>(nil) + private let storage = Mutex?>(nil) - func set(_ store: Store) { + func set(_ store: TransactionStore) { storage.withLock { value in precondition(value == nil) value = store } } - func get() -> Store { + func get() -> TransactionStore { storage.withLock { value in guard let value else { - preconditionFailure("StoreHolder was read before initialization.") + preconditionFailure("TransactionStoreHolder was read before initialization.") } return value } @@ -119,28 +160,48 @@ where } actor ControlledEntitlementQuery { - private var requests: [CheckedContinuation<[StoreTransactionSnapshot], any Error>] = [] + private struct Request { + let id: UUID + let continuation: CheckedContinuation<[StoreTransactionSnapshot], any Error> + } + + private var requests: [Request] = [] private let started = TestSignal() func next() async throws -> [StoreTransactionSnapshot] { - await started.send() - return try await withCheckedThrowingContinuation { continuation in - requests.append(continuation) + try Task.checkCancellation() + let id = UUID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + requests.append(Request(id: id, continuation: continuation)) + Task { await started.send() } + } + } onCancel: { + Task { await self.cancelRequest(id) } } } - func waitForRequest(_ count: Int) async { - await started.wait(for: count) + func waitForRequest(_ count: Int) async throws { + try await started.wait(for: count) } func succeed(_ snapshots: [StoreTransactionSnapshot]) { precondition(!requests.isEmpty) - requests.removeFirst().resume(returning: snapshots) + requests.removeFirst().continuation.resume(returning: snapshots) } func fail(_ error: any Error) { precondition(!requests.isEmpty) - requests.removeFirst().resume(throwing: error) + requests.removeFirst().continuation.resume(throwing: error) + } + + private func cancelRequest(_ id: UUID) { + guard let index = requests.firstIndex(where: { $0.id == id }) else { + return + } + requests.remove(at: index).continuation.resume( + throwing: CancellationError() + ) } } @@ -160,13 +221,31 @@ actor EntitlementValueSource { } } +actor UnfinishedValueSource { + private var value: [StoreTransactionDelivery] + + init(_ value: [StoreTransactionDelivery] = []) { + self.value = value + } + + func read() -> [StoreTransactionDelivery] { + value + } + + func replace(with value: [StoreTransactionDelivery]) { + self.value = value + } +} + func makeSnapshot( id: UInt64, productID: String = "product", + productType: Product.ProductType = .consumable, purchaseDate: Date? = nil, signedDate: Date? = nil, jws: String? = nil, - revocationDate: Date? = nil + revocationDate: Date? = nil, + isUpgraded: Bool = false ) -> StoreTransactionSnapshot { let purchaseDate = purchaseDate ?? Date(timeIntervalSince1970: TimeInterval(id)) return StoreTransactionSnapshot( @@ -174,14 +253,20 @@ func makeSnapshot( originalID: id, productID: productID, subscriptionGroupID: nil, - productType: .consumable, + productType: productType, + environment: .xcode, + offer: nil, + storefrontID: "143441", + storefrontCountryCode: "USA", + price: nil, + currency: nil, purchaseDate: purchaseDate, originalPurchaseDate: purchaseDate, expirationDate: nil, revocationDate: revocationDate, revocationReason: nil, purchasedQuantity: 1, - isUpgraded: false, + isUpgraded: isUpgraded, ownershipType: .purchased, reason: .purchase, appAccountToken: nil, @@ -208,13 +293,22 @@ struct TestSourceFixture: Sendable { let source: StoreTransactionSource let updates: AsyncStream.Continuation let unfinished: AsyncStream.Continuation + let subscriptionStatusUpdates: AsyncStream.Continuation let updateTermination: TestSignal + let subscriptionStatusDeliveryCount: TestSignal + let subscriptionStatusTermination: TestSignal let entitlementQueryCount: TestSignal init( currentEntitlements: @escaping @Sendable () async throws -> [StoreTransactionSnapshot] = { [] }, + currentEntitlementVerificationFailures: + @escaping @Sendable () async + -> [StoreTransactionVerificationError] = { [] }, + queryUnfinished: + @escaping @Sendable () async + -> [StoreTransactionDelivery] = { [] }, history: @escaping @Sendable (Product.ID) async throws -> [StoreTransactionSnapshot] = { _ in [] }, @@ -222,14 +316,23 @@ struct TestSourceFixture: Sendable { ) { let updatePair = AsyncStream.makeStream() let unfinishedPair = AsyncStream.makeStream() + let subscriptionStatusPair = AsyncStream.makeStream() let updateTermination = TestSignal() + let subscriptionStatusDeliveryCount = TestSignal() + let subscriptionStatusTermination = TestSignal() let entitlementQueryCount = TestSignal() updatePair.continuation.onTermination = { _ in Task { await updateTermination.send() } } + subscriptionStatusPair.continuation.onTermination = { _ in + Task { await subscriptionStatusTermination.send() } + } self.updates = updatePair.continuation self.unfinished = unfinishedPair.continuation + self.subscriptionStatusUpdates = subscriptionStatusPair.continuation self.updateTermination = updateTermination + self.subscriptionStatusDeliveryCount = subscriptionStatusDeliveryCount + self.subscriptionStatusTermination = subscriptionStatusTermination self.entitlementQueryCount = entitlementQueryCount self.source = StoreTransactionSource( runUpdates: { consume in @@ -242,10 +345,21 @@ struct TestSourceFixture: Sendable { await consume(delivery) } }, + runSubscriptionStatusUpdates: { consume in + for await _ in subscriptionStatusPair.stream { + await subscriptionStatusDeliveryCount.send() + await consume() + } + }, currentEntitlements: { await entitlementQueryCount.send() - return try await currentEntitlements() + return CurrentEntitlementQueryResult( + snapshots: try await currentEntitlements(), + verificationFailures: + await currentEntitlementVerificationFailures() + ) }, + queryUnfinished: queryUnfinished, history: history, synchronize: synchronize, purchaseDelivery: { result in diff --git a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift index e25426a..2285333 100644 --- a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift +++ b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import StoreTransactionKit -@Suite("TransactionProcessingCore") +@Suite("TransactionProcessingCore", .timeLimit(.minutes(1))) struct TransactionProcessingCoreTests { @Test("durable handling precedes finish") func handlerPrecedesFinish() async throws { @@ -12,16 +12,17 @@ struct TransactionProcessingCoreTests { let core = TransactionProcessingCore { _ in await events.append("handle-start") await handlerStarted.send() - await gate.wait() + try await gate.wait() await events.append("handle-end") } let snapshot = makeSnapshot(id: 1) let receipt = await core.accept( makeEnvelope(snapshot: snapshot) { await events.append("finish") - }) + } + ).receipt - await handlerStarted.wait(for: 1) + try await handlerStarted.wait(for: 1) #expect(await events.snapshot() == ["handle-start"]) await gate.open() _ = try await receipt.terminalValue() @@ -49,12 +50,14 @@ struct TransactionProcessingCoreTests { let first = await core.accept(envelope) await #expect(throws: TestFailure.self) { - _ = try await first.terminalValue() + _ = try await first.receipt.terminalValue() } #expect(await finishes.value() == 0) let second = await core.accept(envelope) - _ = try await second.terminalValue() + _ = try await second.receipt.terminalValue() + #expect(first.role == .owner) + #expect(second.role == .owner) #expect(await attempts.value() == 2) #expect(await finishes.value() == 1) await core.finishInputAndDrain() @@ -70,7 +73,7 @@ struct TransactionProcessingCoreTests { let core = TransactionProcessingCore { _ in await handles.send() await handlerStarted.send() - await handlerGate.wait() + try await handlerGate.wait() } let snapshot = makeSnapshot(id: 3) let first = await core.accept( @@ -80,7 +83,7 @@ struct TransactionProcessingCoreTests { ) { await firstFinish.send() }) - await handlerStarted.wait(for: 1) + try await handlerStarted.wait(for: 1) let duplicate = await core.accept( makeEnvelope( snapshot: snapshot, @@ -90,8 +93,8 @@ struct TransactionProcessingCoreTests { }) await handlerGate.open() - _ = try await first.terminalValue() - _ = try await duplicate.terminalValue() + _ = try await first.receipt.terminalValue() + _ = try await duplicate.receipt.terminalValue() let completed = await core.accept( makeEnvelope( snapshot: snapshot, @@ -99,8 +102,11 @@ struct TransactionProcessingCoreTests { ) { await duplicateFinish.send() }) - _ = try await completed.terminalValue() + _ = try await completed.receipt.terminalValue() + #expect(first.role == .owner) + #expect(duplicate.role == .inFlightObserver) + #expect(completed.role == .completedObserver) #expect(await handles.value() == 1) #expect(await firstFinish.value() == 1) #expect(await duplicateFinish.value() == 0) @@ -116,22 +122,22 @@ struct TransactionProcessingCoreTests { await events.append("handle-\(snapshot.id)") if snapshot.id == 1 { await firstStarted.send() - await firstGate.wait() + try await firstGate.wait() } } let first = await core.accept( makeEnvelope(snapshot: makeSnapshot(id: 1)) { await events.append("finish-1") }) - await firstStarted.wait(for: 1) + try await firstStarted.wait(for: 1) let second = await core.accept( makeEnvelope(snapshot: makeSnapshot(id: 2)) { await events.append("finish-2") }) await firstGate.open() - _ = try await first.terminalValue() - _ = try await second.terminalValue() + _ = try await first.receipt.terminalValue() + _ = try await second.receipt.terminalValue() #expect( await events.snapshot() == [ "handle-1", "finish-1", "handle-2", "finish-2", From c1f85e6fd13ad163c715998173271fcf4daeda2e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:38:15 +0900 Subject: [PATCH 02/17] test(storekit): add app-hosted StoreKit scenarios Exercise direct and external purchases, launch reconciliation, upgrade and downgrade transitions, cancellation through expiration, renewal, refunds, restore retry, and Ask to Buy using event-driven StoreKitTest assertions. --- Tools/TestApp/README.md | 50 + Tools/TestApp/StoreKitTest.storekit | 153 +++ .../StoreTransactionKitIntegrationTests.swift | 883 ++++++++++++++++++ .../project.pbxproj | 474 ++++++++++ ...oreTransactionKitIntegrationTests.xcscheme | 116 +++ .../contents.xcworkspacedata | 7 + .../StoreTransactionKitTestApp.swift | 10 + 7 files changed, 1693 insertions(+) create mode 100644 Tools/TestApp/README.md create mode 100644 Tools/TestApp/StoreKitTest.storekit create mode 100644 Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift create mode 100644 Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj create mode 100644 Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme create mode 100644 Tools/TestApp/StoreTransactionKitTestApp.xcworkspace/contents.xcworkspacedata create mode 100644 Tools/TestApp/StoreTransactionKitTestApp/StoreTransactionKitTestApp.swift diff --git a/Tools/TestApp/README.md b/Tools/TestApp/README.md new file mode 100644 index 0000000..0800718 --- /dev/null +++ b/Tools/TestApp/README.md @@ -0,0 +1,50 @@ +# StoreKit Integration Tests + +The app host gives StoreKit Test a real application process while testing the +public `TransactionStore` API from an external target. `StoreKitTest.storekit` +defines Plus and Pro as two service levels in the same subscription group and +Lifetime as a non-consumable entitlement. + +The serialized suite covers: + +- direct purchases and external `Transaction.updates` handling through finish +- launch reconciliation for an existing purchase +- launch reconciliation for cancelled-active and already-expired subscriptions +- unfinished purchase replay until durable handling succeeds and finishes it +- interrupted purchases that resume through `Transaction.updates` +- verification failures that are reported and remain unfinished +- immediate Plus-to-Pro upgrades +- Pro-to-Plus downgrades at renewal +- cancellation remaining entitled until explicit expiration +- renewal transaction lineage and durable handling before status publication +- refunds +- empty, existing-entitlement, and failed-then-retried restores +- Ask to Buy approval and decline + +Run the suite on an installed iOS Simulator. StoreKit Test owns one environment +per process, so parallel test execution must remain disabled. + +```sh +xcodebuild test \ + -workspace Tools/TestApp/StoreTransactionKitTestApp.xcworkspace \ + -scheme StoreTransactionKitIntegrationTests \ + -destination 'platform=iOS Simulator,id=SIMULATOR_UDID' \ + -parallel-testing-enabled NO +``` + +The tests wait for observable state changes with cancellation-aware event +signals. They don't use fixed sleeps or accelerated wall-clock time. A suite +time limit exists only to surface a missing event as a failed test instead of +hanging the test process. + +CI runs this suite with Xcode 26.5 on the iOS 26.4 simulator available in the +GitHub macOS 26 image. The same suite is also validated locally with Xcode 26.5 +on iOS 18.6 to cover the supported iOS 18 line. + +Local StoreKit testing doesn't validate App Store Connect configuration, +App Store Server Notifications, cross-device propagation, Family Sharing or +Ask to Buy with real accounts, offer eligibility, or the production purchase +and restore sheets. Validate those boundaries separately in Sandbox before +release. Billing retry and grace-period transitions also remain Sandbox +scenarios because the StoreKit query doesn't complete reliably after forcing a +billing failure with Xcode 26.5 on the supported iOS 18.6 runtime. diff --git a/Tools/TestApp/StoreKitTest.storekit b/Tools/TestApp/StoreKitTest.storekit new file mode 100644 index 0000000..c7bfdcf --- /dev/null +++ b/Tools/TestApp/StoreKitTest.storekit @@ -0,0 +1,153 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "StoreTransactionKitTests", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "9.99", + "familyShareable" : false, + "internalID" : "StoreTransactionKitLifetime", + "localizations" : [ + { + "description" : "The lifetime entitlement used by integration tests.", + "displayName" : "Lifetime", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.lifetime", + "referenceName" : "Lifetime", + "type" : "NonConsumable" + } + ], + "settings" : { + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, + "_disableDialogs" : true, + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "enabled" : false, + "name" : "Load Products" + }, + { + "enabled" : false, + "name" : "Purchase" + }, + { + "enabled" : false, + "name" : "Verification" + }, + { + "enabled" : false, + "name" : "App Store Sync" + }, + { + "enabled" : false, + "name" : "Subscription Status" + }, + { + "enabled" : false, + "name" : "App Transaction" + }, + { + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ], + "_timeRate" : 0 + }, + "subscriptionGroups" : [ + { + "id" : "StoreTransactionKitSubscriptionGroup", + "localizations" : [ + + ], + "name" : "Plans", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "1.99", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "StoreTransactionKitPlus", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "The Plus plan used by integration tests.", + "displayName" : "Plus", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.plus", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Plus", + "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "3.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "StoreTransactionKitPro", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "The Pro plan used by integration tests.", + "displayName" : "Pro", + "locale" : "en_US" + } + ], + "productID" : "com.example.StoreTransactionKit.pro", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Pro", + "subscriptionGroupID" : "StoreTransactionKitSubscriptionGroup", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift new file mode 100644 index 0000000..ac6d4c1 --- /dev/null +++ b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift @@ -0,0 +1,883 @@ +import Foundation +import Observation +import StoreKit +import StoreKitTest +import StoreTransactionKit +import Testing + +private enum Entitlement: String, Hashable, Sendable { + case lifetime = "com.example.StoreTransactionKit.lifetime" + case plus = "com.example.StoreTransactionKit.plus" + case pro = "com.example.StoreTransactionKit.pro" +} + +@Suite(.serialized, .timeLimit(.minutes(1))) +@MainActor +struct StoreTransactionKitIntegrationTests { + @Test + func externalPurchaseIsHandledAndFinishedBeforePublication() async throws { + let session = try makeTestSession() + defer { + session.resetToDefaultState() + session.clearTransactions() + } + let handlerStarted = TestSignal() + let handlerRelease = TestSignal() + let handlerCalls = TestSignal() + + let observedStore = ObservedStore( + handleTransaction: { _ in + await handlerCalls.send() + await handlerStarted.send() + try await handlerRelease.wait(for: 1) + }, + reportFailure: { failure in + Issue.record("Unexpected purchase failure: \(failure)") + } + ) + do { + try await observedStore.waitForEntitlements([]) + + _ = try await session.buyProduct( + identifier: Entitlement.lifetime.rawValue + ) + try await handlerStarted.wait(for: 1) + + #expect(observedStore.store.activeEntitlements == []) + await handlerRelease.send() + try await observedStore.waitForEntitlements([.lifetime]) + #expect(await handlerCalls.value() == 1) + } catch { + await handlerRelease.send() + do { + try await observedStore.close() + } catch { + Issue.record("Store cleanup failed: \(error)") + } + throw error + } + try await observedStore.close() + + let replayedHandlerCalls = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await replayedHandlerCalls.send() + }, + reportFailure: { failure in + Issue.record("Unexpected post-finish failure: \(failure)") + } + ) { observedStore in + try await observedStore.waitForEntitlements([.lifetime]) + #expect(await replayedHandlerCalls.value() == 0) + } + } + + @Test + func directPurchaseIsProcessedAndPublishesPlus() async throws { + try await withTestContext { context in + let product = try await context.product(.plus) + let appAccountToken = UUID() + let result = try await product.purchase( + options: [.appAccountToken(appAccountToken)] + ) + + let outcome = try await context.store.process(result) + + guard case .completed(let transaction) = outcome else { + Issue.record("Expected a completed direct purchase.") + return + } + #expect(transaction.productID == Entitlement.plus.rawValue) + #expect( + transaction.subscriptionGroupID + == "StoreTransactionKitSubscriptionGroup" + ) + #expect(transaction.productType == .autoRenewable) + #expect(transaction.environment == .xcode) + #expect(transaction.offer == nil) + #expect(!transaction.storefrontID.isEmpty) + #expect(transaction.storefrontCountryCode == "USA") + #expect(transaction.price == Decimal(string: "1.99")) + #expect(transaction.currency?.identifier == "USD") + #expect(transaction.originalID == transaction.id) + #expect(transaction.originalPurchaseDate == transaction.purchaseDate) + #expect(transaction.revocationDate == nil) + #expect(transaction.revocationReason == nil) + #expect(transaction.purchasedQuantity == 1) + #expect(!transaction.isUpgraded) + #expect(transaction.ownershipType == .purchased) + #expect(transaction.reason == .purchase) + #expect(transaction.appAccountToken == appAccountToken) + #expect(!transaction.jwsRepresentation.isEmpty) + #expect(context.store.activeEntitlements == [.plus]) + } + } + + @Test + func launchReconcilesAnExistingPurchase() async throws { + try await withTestContext(preexistingSubscription: .plus) { context in + #expect(context.store.activeEntitlements == [.plus]) + } + } + + @Test + func launchKeepsACancelledSubscriptionUntilItsExpiration() async throws { + try await withTestContext( + preexistingSubscription: .plus, + preexistingPurchaseOptions: [ + .purchaseDate(.now, renewalBehavior: .cancelImmediately) + ] + ) { context in + #expect(context.store.activeEntitlements == [.plus]) + } + } + + @Test + func launchExcludesAnAlreadyExpiredSubscription() async throws { + let purchaseDate = try #require( + Calendar(identifier: .gregorian).date( + byAdding: .month, + value: -2, + to: .now + ) + ) + + try await withTestContext( + preexistingSubscription: .plus, + preexistingPurchaseOptions: [ + .purchaseDate( + purchaseDate, + renewalBehavior: .cancelImmediately + ) + ], + expectedEntitlements: [] + ) { context in + #expect(context.store.activeEntitlements == []) + } + } + + @Test + func unfinishedPurchaseReplaysUntilDurableHandlingSucceeds() async throws { + let session = try makeTestSession() + defer { + session.resetToDefaultState() + session.clearTransactions() + } + _ = try await session.buyProduct(identifier: Entitlement.plus.rawValue) + + let failedHandlerCalls = TestSignal() + let launchDeliveryFailures = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await failedHandlerCalls.send() + throw DurableHandlingFailure() + }, + reportFailure: { failure in + switch failure.source { + case .updates, .unfinished: + await launchDeliveryFailures.send() + case .entitlementRefresh: + break + default: + Issue.record("Unexpected launch failure: \(failure)") + } + } + ) { observedStore in + try await failedHandlerCalls.wait(for: 1) + try await launchDeliveryFailures.wait(for: 1) + try await observedStore.waitForStartupFailure() + #expect(observedStore.store.activeEntitlements == nil) + } + + let successfulHandlerCalls = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await successfulHandlerCalls.send() + }, + reportFailure: { failure in + Issue.record("Unexpected retry failure: \(failure)") + } + ) { observedStore in + try await successfulHandlerCalls.wait(for: 1) + try await observedStore.waitForEntitlements([.plus]) + } + + let postFinishHandlerCalls = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await postFinishHandlerCalls.send() + }, + reportFailure: { failure in + Issue.record("Unexpected post-finish failure: \(failure)") + } + ) { observedStore in + try await observedStore.waitForEntitlements([.plus]) + #expect(await postFinishHandlerCalls.value() == 0) + } + } + + @Test + func interruptedPurchaseCompletesAfterTheIssueIsResolved() async throws { + try await withTestContext { context in + context.session.interruptedPurchasesEnabled = true + let product = try await context.product(.plus) + let result = try await product.purchase() + guard case .pending = result else { + Issue.record("Expected the interrupted purchase to remain pending.") + return + } + let interrupted = try #require( + context.session.allTransactions().first { + $0.productIdentifier == Entitlement.plus.rawValue + && $0.hasPurchaseIssue + } + ) + + try context.session.resolveIssueForTransaction( + identifier: interrupted.identifier + ) + + try await context.waitForEntitlements([.plus]) + } + } + + @Test + func unverifiedUpdateIsReportedAndRemainsUnfinished() async throws { + let session = try makeTestSession() + defer { + session.resetToDefaultState() + session.clearTransactions() + } + let rejectedHandlerCalls = TestSignal() + let updateFailures = TestSignal() + + try await withObservedStore( + handleTransaction: { _ in + await rejectedHandlerCalls.send() + }, + reportFailure: { failure in + if case .updates = failure.source { + await updateFailures.send() + } + } + ) { observedStore in + try await observedStore.waitForEntitlements([]) + try await session.setSimulatedError( + .verification(.invalidSignature), + forAPI: .verification + ) + + _ = try await session.buyProduct(identifier: Entitlement.plus.rawValue) + try await updateFailures.wait(for: 1) + + #expect(await rejectedHandlerCalls.value() == 0) + #expect(observedStore.store.activeEntitlements == []) + } + + let unfinishedFailures = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await rejectedHandlerCalls.send() + }, + reportFailure: { failure in + if case .unfinished = failure.source { + await unfinishedFailures.send() + } + } + ) { _ in + try await unfinishedFailures.wait(for: 1) + #expect(await rejectedHandlerCalls.value() == 0) + } + } + + @Test + func upgradeFromPlusToProPublishesOnlyPro() async throws { + try await withTestContext { context in + _ = try await context.session.buyProduct(identifier: Entitlement.plus.rawValue) + try await context.waitForEntitlements([.plus]) + + _ = try await context.session.buyProduct(identifier: Entitlement.pro.rawValue) + + try await context.waitForEntitlements([.pro]) + #expect( + context.store.entitlements?.transactions.contains { + $0.productID == Entitlement.plus.rawValue && $0.isUpgraded + } == true + ) + } + } + + @Test + func downgradeFromProToPlusChangesAtRenewal() async throws { + try await withTestContext { context in + _ = try await context.session.buyProduct(identifier: Entitlement.pro.rawValue) + try await context.waitForEntitlements([.pro]) + + _ = try await context.session.buyProduct(identifier: Entitlement.plus.rawValue) + + let preRenewalEntitlements = try await context.store.refreshEntitlements() + #expect( + preRenewalEntitlements.transactions.map(\.productID) + == [Entitlement.pro.rawValue] + ) + #expect(context.store.activeEntitlements == [.pro]) + try context.session.forceRenewalOfSubscription( + productIdentifier: Entitlement.pro.rawValue + ) + try await context.waitForEntitlements([.plus]) + } + } + + @Test + func cancellationKeepsPlusUntilExpirationThenRemovesIt() async throws { + try await withTestContext { context in + let transaction = try await context.session.buyProduct( + identifier: Entitlement.plus.rawValue + ) + try await context.waitForEntitlements([.plus]) + + try context.session.disableAutoRenewForTransaction( + identifier: UInt(transaction.id) + ) + let cancelledTransaction = try #require( + context.session.allTransactions().first { + $0.identifier == UInt(transaction.id) + } + ) + #expect(!cancelledTransaction.autoRenewingEnabled) + + let cancelledEntitlements = try await context.store.refreshEntitlements() + #expect( + cancelledEntitlements.transactions.map(\.productID) + == [Entitlement.plus.rawValue] + ) + #expect(context.store.activeEntitlements == [.plus]) + + try context.session.expireSubscription( + productIdentifier: Entitlement.plus.rawValue + ) + + try await context.waitForEntitlements([]) + } + } + + @Test + func renewalPublishesTheNewTransactionLineage() async throws { + try await withTestContext { context in + let initial = try await context.session.buyProduct( + identifier: Entitlement.plus.rawValue + ) + try await context.waitForEntitlements([.plus]) + + try context.session.forceRenewalOfSubscription( + productIdentifier: Entitlement.plus.rawValue + ) + + let renewal = try await context.waitForTransaction( + productID: Entitlement.plus.rawValue, + excluding: initial.id + ) + #expect(renewal.originalID == initial.originalID) + #expect(renewal.reason == .renewal) + #expect(context.store.activeEntitlements == [.plus]) + } + } + + @Test + func renewalWaitsForDurableHandlingBeforeStatusPublication() async throws { + let session = try makeTestSession() + defer { + session.resetToDefaultState() + session.clearTransactions() + } + let renewalHandlerStarted = TestSignal() + let renewalHandlerRelease = TestSignal() + let observedStore = ObservedStore( + handleTransaction: { transaction in + guard transaction.reason == .renewal else { return } + await renewalHandlerStarted.send() + try await renewalHandlerRelease.wait(for: 1) + }, + reportFailure: { failure in + Issue.record("Unexpected renewal failure: \(failure)") + } + ) + + do { + try await observedStore.waitForEntitlements([]) + let initial = try await session.buyProduct( + identifier: Entitlement.plus.rawValue + ) + try await observedStore.waitForEntitlements([.plus]) + + try session.forceRenewalOfSubscription( + productIdentifier: Entitlement.plus.rawValue + ) + try await renewalHandlerStarted.wait(for: 1) + + #expect( + observedStore.store.entitlements?.transactions.first?.id + == initial.id + ) + await renewalHandlerRelease.send() + let renewal = try await observedStore.waitForTransaction( + productID: Entitlement.plus.rawValue, + excluding: initial.id + ) + #expect(renewal.reason == .renewal) + #expect(renewal.originalID == initial.originalID) + } catch { + await renewalHandlerRelease.send() + do { + try await observedStore.close() + } catch { + Issue.record("Store cleanup failed: \(error)") + } + throw error + } + try await observedStore.close() + + let replayedHandlerCalls = TestSignal() + try await withObservedStore( + handleTransaction: { _ in + await replayedHandlerCalls.send() + }, + reportFailure: { failure in + Issue.record("Unexpected post-renewal failure: \(failure)") + } + ) { observedStore in + try await observedStore.waitForEntitlements([.plus]) + #expect(await replayedHandlerCalls.value() == 0) + } + } + + @Test + func refundRemovesTheEntitlement() async throws { + try await withTestContext { context in + let transaction = try await context.session.buyProduct( + identifier: Entitlement.plus.rawValue + ) + try await context.waitForEntitlements([.plus]) + + try context.session.refundTransaction(identifier: UInt(transaction.id)) + + try await context.waitForEntitlements([]) + } + } + + @Test + func restoreWithNoPurchasesPublishesResolvedEmpty() async throws { + try await withTestContext { context in + let entitlements = try await context.store.restorePurchases() + + #expect(entitlements.transactions.isEmpty) + #expect(context.store.activeEntitlements == []) + } + } + + @Test + func restoreReturnsAnExistingEntitlement() async throws { + try await withTestContext(preexistingSubscription: .plus) { context in + let entitlements = try await context.store.restorePurchases() + + #expect( + entitlements.transactions.map(\.productID) + == [Entitlement.plus.rawValue] + ) + #expect(context.store.activeEntitlements == [.plus]) + } + } + + @Test + func restorePropagatesAppStoreSyncFailureAndCanRetry() async throws { + try await withTestContext { context in + let injectedFailure = SKTestFailures.AppStoreSync.generic( + .networkError(URLError(.notConnectedToInternet)) + ) + try await context.session.setSimulatedError( + injectedFailure, + forAPI: .appStoreSync + ) + #expect( + await context.session.simulatedError(forAPI: .appStoreSync) + == injectedFailure + ) + + do { + _ = try await context.store.restorePurchases() + Issue.record("Expected restorePurchases() to fail.") + } catch StoreKitError.networkError(let error) { + #expect(error.code == .notConnectedToInternet) + } catch { + Issue.record("Unexpected restore error: \(error)") + } + + try await context.session.setSimulatedError( + nil, + forAPI: .appStoreSync + ) + #expect( + await context.session.simulatedError(forAPI: .appStoreSync) + == nil + ) + let entitlements = try await context.store.restorePurchases() + #expect(entitlements.transactions.isEmpty) + #expect(context.store.activeEntitlements == []) + } + } + + @Test + func askToBuyApprovalArrivesThroughUpdates() async throws { + try await withTestContext { context in + context.session.askToBuyEnabled = true + let product = try await context.product(.plus) + let result = try await product.purchase() + + let outcome = try await context.store.process(result) + + guard case .pending = outcome else { + Issue.record("Expected an Ask to Buy purchase to remain pending.") + return + } + let pending = try #require( + context.session.allTransactions().first { + $0.pendingAskToBuyConfirmation + } + ) + try context.session.approveAskToBuyTransaction( + identifier: pending.identifier + ) + try await context.waitForEntitlements([.plus]) + } + } + + @Test + func askToBuyDeclineDoesNotGrantAnEntitlement() async throws { + try await withTestContext { context in + context.session.askToBuyEnabled = true + let product = try await context.product(.plus) + let result = try await product.purchase() + + let outcome = try await context.store.process(result) + + guard case .pending = outcome else { + Issue.record("Expected an Ask to Buy purchase to remain pending.") + return + } + let pending = try #require( + context.session.allTransactions().first { + $0.pendingAskToBuyConfirmation + } + ) + try context.session.declineAskToBuyTransaction( + identifier: pending.identifier + ) + + let entitlements = try await context.store.restorePurchases() + #expect(entitlements.transactions.isEmpty) + #expect(context.store.activeEntitlements == []) + #expect( + context.session.allTransactions().allSatisfy { + !$0.pendingAskToBuyConfirmation + } + ) + } + } + + private func withTestContext( + preexistingSubscription: Entitlement? = nil, + preexistingPurchaseOptions: Set = [], + expectedEntitlements: Set? = nil, + _ body: (TestContext) async throws -> Void + ) async throws { + let context = try await TestContext( + preexistingSubscription: preexistingSubscription, + preexistingPurchaseOptions: preexistingPurchaseOptions, + expectedEntitlements: expectedEntitlements + ) + do { + try await body(context) + } catch { + do { + try await context.close() + } catch { + Issue.record("Store cleanup failed: \(error)") + } + throw error + } + try await context.close() + } +} + +@MainActor +private final class TestContext { + let session: SKTestSession + private let observedStore: ObservedStore + + var store: TransactionStore { + observedStore.store + } + + init( + preexistingSubscription: Entitlement? = nil, + preexistingPurchaseOptions: Set = [], + expectedEntitlements: Set? = nil + ) async throws { + let session = try makeTestSession() + self.session = session + if let preexistingSubscription { + _ = try await session.buyProduct( + identifier: preexistingSubscription.rawValue, + options: preexistingPurchaseOptions + ) + } + + let observedStore = ObservedStore( + handleTransaction: { _ in }, + reportFailure: { error in + Issue.record("Background transaction failure: \(error)") + } + ) + self.observedStore = observedStore + let expected = + expectedEntitlements + ?? preexistingSubscription.map { [$0] } + ?? [] + try await observedStore.waitForEntitlements(expected) + #expect(store.startupError == nil) + } + + func product(_ entitlement: Entitlement) async throws -> Product { + try #require( + try await Product.products(for: [entitlement.rawValue]).first + ) + } + + func waitForEntitlements( + _ expected: Set + ) async throws { + try await observedStore.waitForEntitlements(expected) + } + + func waitForTransaction( + productID: String, + excluding transactionID: UInt64 + ) async throws -> StoreTransactionSnapshot { + try await observedStore.waitForTransaction( + productID: productID, + excluding: transactionID + ) + } + + func close() async throws { + defer { + session.resetToDefaultState() + session.clearTransactions() + } + try await observedStore.close() + } +} + +@MainActor +private final class ObservedStore { + let store: TransactionStore + let observation: TransactionStoreObservation + + init( + handleTransaction: + @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, + reportFailure: + @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void + ) { + let store = TransactionStore( + handleTransaction: handleTransaction, + reportFailure: reportFailure + ) + self.store = store + self.observation = TransactionStoreObservation(store: store) + } + + func waitForEntitlements(_ expected: Set) async throws { + while true { + let generation = observation.generation + guard store.activeEntitlements != expected else { return } + try await observation.waitForChange(after: generation) + } + } + + func waitForStartupFailure() async throws { + while true { + let generation = observation.generation + guard store.startupError == nil else { return } + try await observation.waitForChange(after: generation) + } + } + + func waitForTransaction( + productID: String, + excluding transactionID: UInt64 + ) async throws -> StoreTransactionSnapshot { + while true { + let generation = observation.generation + if let transaction = store.entitlements?.transactions.first(where: { + $0.productID == productID && $0.id != transactionID + }) { + return transaction + } + try await observation.waitForChange(after: generation) + } + } + + func close() async throws { + observation.finish() + try await store.close() + } +} + +@MainActor +private func withObservedStore( + handleTransaction: + @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, + reportFailure: + @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void, + _ body: (ObservedStore) async throws -> Void +) async throws { + let observedStore = ObservedStore( + handleTransaction: handleTransaction, + reportFailure: reportFailure + ) + do { + try await body(observedStore) + } catch { + do { + try await observedStore.close() + } catch { + Issue.record("Store cleanup failed: \(error)") + } + throw error + } + try await observedStore.close() +} + +@MainActor +private final class TransactionStoreObservation { + private weak var store: TransactionStore? + private let changes: AsyncStream + private let continuation: AsyncStream.Continuation + private(set) var generation: UInt64 = 0 + + init(store: TransactionStore) { + let pair = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + self.changes = pair.stream + self.continuation = pair.continuation + self.store = store + observeNextChange() + } + + func waitForChange(after observedGeneration: UInt64) async throws { + guard observedGeneration == generation else { return } + var iterator = changes.makeAsyncIterator() + while observedGeneration == generation { + try Task.checkCancellation() + guard await iterator.next() != nil else { + throw CancellationError() + } + } + } + + func finish() { + store = nil + continuation.finish() + } + + private func observeNextChange() { + guard let store else { return } + withObservationTracking { + _ = store.entitlements + _ = store.startupError + } onChange: { [weak self] in + Task { @MainActor in + self?.didChange() + } + } + } + + private func didChange() { + precondition(generation < .max) + generation += 1 + observeNextChange() + continuation.yield(generation) + } +} + +private actor TestSignal { + private struct Waiter { + let target: Int + let continuation: CheckedContinuation + } + + private var count = 0 + private var waiters: [UUID: Waiter] = [:] + + func send() { + count += 1 + let ready = waiters.filter { $0.value.target <= count } + for (id, waiter) in ready { + waiters.removeValue(forKey: id) + waiter.continuation.resume() + } + } + + func wait(for target: Int) async throws { + guard count < target else { return } + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else if count >= target { + continuation.resume() + } else { + waiters[id] = Waiter( + target: target, + continuation: continuation + ) + } + } + } onCancel: { + Task { await self.cancelWaiter(id) } + } + } + + func value() -> Int { + count + } + + private func cancelWaiter(_ id: UUID) { + waiters.removeValue(forKey: id)?.continuation.resume( + throwing: CancellationError() + ) + } +} + +private struct DurableHandlingFailure: Error {} + +private func makeTestSession() throws -> SKTestSession { + let configuration = try #require( + Bundle(for: BundleToken.self).url( + forResource: "StoreKitTest", + withExtension: "storekit" + ) + ) + let session = try SKTestSession(contentsOf: configuration) + session.disableDialogs = true + session.timeRate = .realTime + session.resetToDefaultState() + session.clearTransactions() + return session +} + +private final class BundleToken {} diff --git a/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7221037 --- /dev/null +++ b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/project.pbxproj @@ -0,0 +1,474 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 0A298711DEBD3B6EB230ABB9 /* StoreTransactionKitIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E227115D1E76A48AC12FD5D6 /* StoreTransactionKitIntegrationTests.swift */; }; + 38FAC3AA36BA020043C15489 /* StoreTransactionKitTestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4854362AB2597C739C5FD6 /* StoreTransactionKitTestApp.swift */; }; + 8139D7CCFBB15D9CE28B97C0 /* StoreKitTest.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 708FCFDBC45AA604121AE7DC /* StoreKitTest.storekit */; }; + DC89CACC7D41724581221AC2 /* StoreTransactionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 979A820F5F67B3E4C1536AC7 /* StoreTransactionKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 1D294771080BE03EF6F71236 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 3ACC78A3FE42CBF0145C6042 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EABBA04AB93715D8FB560B24; + remoteInfo = StoreTransactionKitTestApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 16F16063DB121EFC3B4AF293 /* StoreTransactionKitTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StoreTransactionKitTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1E4854362AB2597C739C5FD6 /* StoreTransactionKitTestApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StoreTransactionKitTestApp.swift; sourceTree = ""; }; + 708FCFDBC45AA604121AE7DC /* StoreKitTest.storekit */ = {isa = PBXFileReference; includeInIndex = 1; path = StoreKitTest.storekit; sourceTree = ""; }; + CA0A7E6C9BFF622EA9194CB9 /* StoreTransactionKitIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StoreTransactionKitIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + E227115D1E76A48AC12FD5D6 /* StoreTransactionKitIntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StoreTransactionKitIntegrationTests.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4CD8555B8E9244B36EDA7453 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC89CACC7D41724581221AC2 /* StoreTransactionKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CBE70F6D15630C643FB9301D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 168BF7BE37B1E4FB20E86CBF /* Products */ = { + isa = PBXGroup; + children = ( + 16F16063DB121EFC3B4AF293 /* StoreTransactionKitTestApp.app */, + CA0A7E6C9BFF622EA9194CB9 /* StoreTransactionKitIntegrationTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 69B93CA4D260A36EC529996F = { + isa = PBXGroup; + children = ( + 168BF7BE37B1E4FB20E86CBF /* Products */, + F12449B1ECF7C1E4374C8275 /* StoreTransactionKitTestApp */, + F2DC827B3C8063624D6F0CE3 /* StoreTransactionKitIntegrationTests */, + 708FCFDBC45AA604121AE7DC /* StoreKitTest.storekit */, + ); + sourceTree = ""; + }; + F12449B1ECF7C1E4374C8275 /* StoreTransactionKitTestApp */ = { + isa = PBXGroup; + children = ( + 1E4854362AB2597C739C5FD6 /* StoreTransactionKitTestApp.swift */, + ); + name = StoreTransactionKitTestApp; + path = StoreTransactionKitTestApp; + sourceTree = ""; + }; + F2DC827B3C8063624D6F0CE3 /* StoreTransactionKitIntegrationTests */ = { + isa = PBXGroup; + children = ( + E227115D1E76A48AC12FD5D6 /* StoreTransactionKitIntegrationTests.swift */, + ); + name = StoreTransactionKitIntegrationTests; + path = StoreTransactionKitIntegrationTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 578B204A3251F74CCAEF5433 /* StoreTransactionKitIntegrationTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D4273B97CE0C94E099541CBF /* Build configuration list for PBXNativeTarget "StoreTransactionKitIntegrationTests" */; + buildPhases = ( + A36C3485111ADFCB71E24F0F /* Sources */, + 4CD8555B8E9244B36EDA7453 /* Frameworks */, + A8079E6EBE2E408327BE20C3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + DB2A42933EB09967829A9DF7 /* PBXTargetDependency */, + ); + name = StoreTransactionKitIntegrationTests; + packageProductDependencies = ( + 979A820F5F67B3E4C1536AC7 /* StoreTransactionKit */, + ); + productName = StoreTransactionKitIntegrationTests; + productReference = CA0A7E6C9BFF622EA9194CB9 /* StoreTransactionKitIntegrationTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + EABBA04AB93715D8FB560B24 /* StoreTransactionKitTestApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = A1C4A40D06FA3AB1E1E71D3A /* Build configuration list for PBXNativeTarget "StoreTransactionKitTestApp" */; + buildPhases = ( + 9B6B194DE2758845BD528260 /* Sources */, + CBE70F6D15630C643FB9301D /* Frameworks */, + 8A4B766EB1FF6E94BF0B0FB6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = StoreTransactionKitTestApp; + productName = StoreTransactionKitTestApp; + productReference = 16F16063DB121EFC3B4AF293 /* StoreTransactionKitTestApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 3ACC78A3FE42CBF0145C6042 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + }; + buildConfigurationList = 6C612D14E0C478CD4900FC4D /* Build configuration list for PBXProject "StoreTransactionKitTestApp" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 69B93CA4D260A36EC529996F; + minimizedProjectReferenceProxies = 0; + packageReferences = ( + 685C7318A90AED4AB632C854 /* XCLocalSwiftPackageReference ".." */, + ); + preferredProjectObjectVersion = 100; + productRefGroup = 168BF7BE37B1E4FB20E86CBF /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + EABBA04AB93715D8FB560B24 /* StoreTransactionKitTestApp */, + 578B204A3251F74CCAEF5433 /* StoreTransactionKitIntegrationTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8A4B766EB1FF6E94BF0B0FB6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A8079E6EBE2E408327BE20C3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8139D7CCFBB15D9CE28B97C0 /* StoreKitTest.storekit in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 9B6B194DE2758845BD528260 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 38FAC3AA36BA020043C15489 /* StoreTransactionKitTestApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A36C3485111ADFCB71E24F0F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0A298711DEBD3B6EB230ABB9 /* StoreTransactionKitIntegrationTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + DB2A42933EB09967829A9DF7 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = StoreTransactionKitTestApp; + target = EABBA04AB93715D8FB560B24 /* StoreTransactionKitTestApp */; + targetProxy = 1D294771080BE03EF6F71236 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 4DBB1CF4BF0F532FED9E0127 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + StoreKitTest, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.IntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/StoreTransactionKitTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/StoreTransactionKitTestApp"; + }; + name = Debug; + }; + 7351B6A5190CAAE26AB19EB0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + 9FDF3FAFCE0A76D4402541CA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + A81D7E05599894178C7EE383 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.TestApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A8DB151A87C5162DF2D5E9AD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PLATFORM_DIR)/Developer/Library/Frameworks", + ); + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + StoreKitTest, + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.IntegrationTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/StoreTransactionKitTestApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/StoreTransactionKitTestApp"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F789B675F7B22BE59E3CA5F8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.4; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.example.StoreTransactionKit.TestApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 6C612D14E0C478CD4900FC4D /* Build configuration list for PBXProject "StoreTransactionKitTestApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9FDF3FAFCE0A76D4402541CA /* Debug */, + 7351B6A5190CAAE26AB19EB0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A1C4A40D06FA3AB1E1E71D3A /* Build configuration list for PBXNativeTarget "StoreTransactionKitTestApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F789B675F7B22BE59E3CA5F8 /* Release */, + A81D7E05599894178C7EE383 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D4273B97CE0C94E099541CBF /* Build configuration list for PBXNativeTarget "StoreTransactionKitIntegrationTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8DB151A87C5162DF2D5E9AD /* Release */, + 4DBB1CF4BF0F532FED9E0127 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 685C7318A90AED4AB632C854 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 979A820F5F67B3E4C1536AC7 /* StoreTransactionKit */ = { + isa = XCSwiftPackageProductDependency; + package = 685C7318A90AED4AB632C854 /* XCLocalSwiftPackageReference ".." */; + productName = StoreTransactionKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 3ACC78A3FE42CBF0145C6042 /* Project object */; +} diff --git a/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme new file mode 100644 index 0000000..a91f1d2 --- /dev/null +++ b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tools/TestApp/StoreTransactionKitTestApp.xcworkspace/contents.xcworkspacedata b/Tools/TestApp/StoreTransactionKitTestApp.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1148920 --- /dev/null +++ b/Tools/TestApp/StoreTransactionKitTestApp.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Tools/TestApp/StoreTransactionKitTestApp/StoreTransactionKitTestApp.swift b/Tools/TestApp/StoreTransactionKitTestApp/StoreTransactionKitTestApp.swift new file mode 100644 index 0000000..e7e58fc --- /dev/null +++ b/Tools/TestApp/StoreTransactionKitTestApp/StoreTransactionKitTestApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct StoreTransactionKitTestApp: App { + var body: some Scene { + WindowGroup { + Text("StoreTransactionKit integration tests") + } + } +} From 4ebdbd88f0648c415040e3998288b012f4bc5a36 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:39:50 +0900 Subject: [PATCH 03/17] ci(storekit): run deterministic integration tests Pin the StoreKit lane to Xcode 26.5 and its iOS 26.4 runtime, disable parallel execution, and include the app-hosted test sources in swift-format linting. --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a96a671..ce594c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: xcodebuild -showsdks - name: Lint Swift formatting - run: xcrun swift-format lint --recursive --strict Sources Tests Package.swift + run: xcrun swift-format lint --recursive --strict Sources Tests Tools/TestApp Package.swift - name: Run Swift package tests run: swift test --build-system swiftbuild --no-parallel @@ -100,40 +100,36 @@ jobs: runs-on: macos-26 timeout-minutes: 20 env: - XCODE_MAJOR: "26" + XCODE_VERSION: "26.5" steps: - name: Check out repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: Resolve latest stable Xcode + - name: Select Xcode run: | - xcode_app="$(ruby <<'RUBY' - xcode_major = ENV.fetch("XCODE_MAJOR") - candidates = Dir["/Applications/Xcode_#{xcode_major}*.app"].select { |path| File.directory?(path) } - candidates = candidates.reject do |path| - labels = [path, File.realpath(path)].map { |candidate| File.basename(candidate).downcase } - labels.any? { |label| label.match?(/beta|release[ _-]?candidate|(?:^|[_. -])rc(?:$|[_. -])/) } - end - selected = candidates.max_by do |path| - File.basename(path)[/Xcode_(\d+(?:\.\d+)*)/, 1].to_s.split(".").map(&:to_i) - end - abort("No stable Xcode #{xcode_major} installation found") unless selected - puts selected - RUBY - )" + xcode_app="/Applications/Xcode_${XCODE_VERSION}.app" + if [[ ! -d "${xcode_app}" ]]; then + echo "Xcode ${XCODE_VERSION} is not installed on the runner." >&2 + exit 1 + fi echo "DEVELOPER_DIR=${xcode_app}/Contents/Developer" >> "$GITHUB_ENV" - echo "Resolved Xcode: ${xcode_app}" + echo "Selected Xcode: ${xcode_app}" + + - name: Show Xcode environment + run: | + xcodebuild -version + swift --version - name: Select iOS Simulator run: | simulator_id="$(xcrun simctl list devices available --json | ruby -rjson -e ' - runtimes = JSON.parse(STDIN.read).fetch("devices").select { |name, _| name.include?(".iOS-") } - _, devices = runtimes.max_by { |name, _| name.scan(/\d+/).map(&:to_i) } - abort("No available iOS runtime found") unless devices + runtimes = JSON.parse(STDIN.read).fetch("devices") + _, devices = runtimes.find { |name, _| name.end_with?(".iOS-26-4") } + abort("The iOS 26.4 Simulator runtime is required for StoreKit tests") unless devices device = devices.find { |candidate| candidate["isAvailable"] && candidate["name"].start_with?("iPhone") } - abort("No available iPhone Simulator found") unless device + abort("No available iPhone with iOS 26.4 found") unless device puts device.fetch("udid") ')" echo "SIMULATOR_ID=${simulator_id}" >> "$GITHUB_ENV" @@ -145,3 +141,13 @@ jobs: -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO + + - name: Run StoreKit integration tests + run: | + xcodebuild test \ + -workspace Tools/TestApp/StoreTransactionKitTestApp.xcworkspace \ + -scheme StoreTransactionKitIntegrationTests \ + -destination "platform=iOS Simulator,id=${SIMULATOR_ID}" \ + -parallel-testing-enabled NO \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO From 4866d7cb79d44bd8919d700dcbad6447b92d6fbb Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:40:46 +0900 Subject: [PATCH 04/17] docs(storekit): clarify lifecycle and entitlement usage Document TransactionStore ownership, unresolved and failed startup states, upgrade-safe entitlement checks, revocation handling, callback reentrancy, promoted purchases, restore cancellation, and stable entitlement ordering. --- README.md | 96 +++++++++++++++---- .../StoreEntitlements.swift | 6 +- .../StoreTransactionKit.md | 33 ++++--- 3 files changed, 100 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 3e3fdcc..80237be 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ durable processing, and `Transaction.finish()` into one process-owned, observable store. It supports iOS 18.4 and later and macOS 15.4 and later. -## Create a Store +## Create a TransactionStore Define the entitlement identifiers in the app. A string-backed enum keeps StoreKit product identifiers typed without requiring a framework protocol. @@ -20,10 +20,20 @@ enum SubscriptionID: String, Hashable, Sendable { actor PurchaseLedger { func apply(_ transaction: StoreTransactionSnapshot) async throws { - try await database.commitPurchase( - transactionID: transaction.id, - productID: transaction.productID - ) + if let revocationDate = transaction.revocationDate { + try await database.revokePurchase( + transactionID: transaction.id, + productID: transaction.productID, + signedDate: transaction.signedDate, + revocationDate: revocationDate + ) + } else { + try await database.commitPurchase( + transactionID: transaction.id, + productID: transaction.productID, + signedDate: transaction.signedDate + ) + } } } @@ -37,8 +47,8 @@ actor StoreDiagnostics { func makeStore( ledger: PurchaseLedger, diagnostics: StoreDiagnostics -) -> Store { - Store( +) -> TransactionStore { + TransactionStore( handleTransaction: { transaction in try await ledger.apply(transaction) }, @@ -49,15 +59,27 @@ func makeStore( } ``` -`Store` is `@MainActor` and `@Observable`. It starts `Transaction.unfinished` -and `Transaction.updates` monitoring during initialization. Retain one instance -in the application's process-lifetime composition. +`TransactionStore` is `@MainActor` and `@Observable`. It monitors +`Transaction.unfinished`, `Transaction.updates`, and subscription status +changes during initialization. Retain one instance in the application's +process-lifetime composition. `activeEntitlements` contains the app-defined identifiers represented by StoreKit's current entitlements. It is `nil` while the initial entitlement query is unresolved and becomes a non-`nil` empty set when no known -subscription is active. `entitlements` contains the complete verified -snapshot, including product identifiers outside `SubscriptionID`. +identifier matches a current entitlement. A subscription superseded by an +upgrade is excluded from this set. `entitlements` contains the complete +verified snapshot, including superseded transactions and product identifiers +outside `SubscriptionID`. Use `StoreTransactionSnapshot.subscriptionGroupID` +when the app grants access at subscription-group rather than product-tier +granularity. A current-entitlement element that StoreKit can't verify is +omitted from the projection and delivered to `reportFailure` with source +`.currentEntitlementVerification`. + +For renewal dates, grace periods, billing retry, and expiration messaging, +read `Product.SubscriptionInfo.Status` directly. `TransactionStore` owns the +durable transaction path and current-entitlement projection, not subscription +status presentation. ## Use SubscriptionStoreView @@ -75,14 +97,29 @@ import StoreKit import SwiftUI struct PremiumStoreView: View { - @Environment(Store.self) private var store + @Environment(TransactionStore.self) private var store + @State private var refreshError: (any Error)? var body: some View { VStack { if let activeEntitlements = store.activeEntitlements { - if activeEntitlements.contains(.yearly) { + if activeEntitlements.contains(.monthly) + || activeEntitlements.contains(.yearly) + { Label("Premium active", systemImage: "checkmark.seal.fill") } + } else if let error = refreshError ?? store.startupError { + Text(error.localizedDescription) + Button("Retry") { + Task { + do { + try await store.refreshEntitlements() + refreshError = nil + } catch { + refreshError = error + } + } + } } else { ProgressView() } @@ -98,21 +135,38 @@ struct PremiumStoreView: View { No `onInAppPurchaseCompletion` modifier is needed here. By default, successful StoreKit view purchases are delivered through `Transaction.updates`, which the store already monitors. A non-`nil` completion action replaces that default; -if you add one, pass its successful `Product.PurchaseResult` to -`store.process(_:)`. - -`PurchaseLedger.apply(_:)` must be idempotent because StoreKit delivery is at -least once. It must return only after the business effect is durable. The -store calls `finish()` after that return. +if you add one, pass each `.success` value (the `Product.PurchaseResult`) to +`store.process(_:)`. The action also replaces StoreKit's default failure alert, +so it must own `.failure` presentation or diagnostics. + +StoreTransactionKit exposes an at-least-once handler-delivery contract, so +`PurchaseLedger.apply(_:)` must be idempotent. It must return only after the +business effect is durable. The store calls `finish()` after that return. Treat +purchase and revocation revisions as distinct durable business events; +transaction ID alone is not a sufficient idempotency key for both. The handler +must not call methods on the same store, including through an awaited detached +task, because that creates a dependency cycle with the transaction being +handled. + +Apps that support promoted purchases or applicable win-back flows own +`PurchaseIntent.intents`: complete each intent's product purchase and pass its +result to `store.process(_:)`. Call `store.restorePurchases()` only from an explicit user action because -`AppStore.sync()` may present authentication UI. +`AppStore.sync()` presents authentication UI. Treat `StoreKitError.userCancelled` +as a normal user outcome rather than a diagnostic failure. For product merchandising and UI composition, use Apple's [Getting started with In-App Purchase using StoreKit views](https://developer.apple.com/documentation/storekit/getting-started-with-in-app-purchases-using-storekit-views) and [Implementing a store in your app using the StoreKit API](https://developer.apple.com/documentation/storekit/implementing-a-store-in-your-app-using-the-storekit-api). +## Testing + +The app-hosted StoreKit integration suite runs with `xcodebuild`. See +[Tools/TestApp/README.md](Tools/TestApp/README.md) for the scenarios and +command. + ## License StoreTransactionKit is available under the MIT License. diff --git a/Sources/StoreTransactionKit/StoreEntitlements.swift b/Sources/StoreTransactionKit/StoreEntitlements.swift index 39bf372..2c0b7f1 100644 --- a/Sources/StoreTransactionKit/StoreEntitlements.swift +++ b/Sources/StoreTransactionKit/StoreEntitlements.swift @@ -1,6 +1,10 @@ /// A complete, ordered projection of the current StoreKit entitlements. public struct StoreEntitlements: Sendable, Equatable { - /// Verified current entitlements in StoreTransactionKit's documented order. + /// Verified current entitlements in a stable order. + /// + /// Transactions are ordered by product identifier UTF-8 bytes ascending, + /// then purchase date ascending, transaction identifier ascending, and + /// exact JWS UTF-8 bytes ascending. public let transactions: [StoreTransactionSnapshot] package init(transactions: [StoreTransactionSnapshot]) { diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index 136815f..d7d745a 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -5,35 +5,41 @@ verified transaction only after its durable business effect succeeds. ## Overview -StoreKit can deliver a purchase as a direct ``StoreKit/Product/PurchaseResult``, -through ``StoreKit/Transaction/updates``, or as unfinished work on a later -launch. ``Store`` normalizes these paths into one verified, FIFO transaction +StoreKit can deliver a purchase as a direct `Product.PurchaseResult`, through +`Transaction.updates`, or as unfinished work on a later launch. +``TransactionStore`` normalizes these paths into one verified, FIFO transaction processor and publishes observable current-entitlement state. Create one store in the application composition root. Supply an idempotent transaction handler that commits the app's business effect before returning. The app defines a string-backed entitlement identifier type; -``Store/activeEntitlements`` then exposes an optional typed set derived from -StoreKit's verified current entitlements. `nil` means the initial entitlement -query remains unresolved; an empty set means the query completed with no -matching entitlement. Pass direct results from custom purchase UI into -``Store/process(_:)``. +``TransactionStore/activeEntitlements`` then exposes an optional typed set +derived from StoreKit's verified current entitlements. `nil` means the initial +entitlement query remains unresolved; an empty set means the query completed +with no matching entitlement. Transactions superseded by a subscription upgrade +remain in the complete snapshot but don't appear in the typed active set. Pass +direct results from custom purchase UI into ``TransactionStore/process(_:)``. The framework owns StoreKit verification, process-local exact-revision coalescing, `finish()`, entitlement refresh, history ordering, restore synchronization, background failure delivery, and explicit shutdown. The app continues to own persistence, server communication, access presentation, and -the concrete purchase scene or window. +the concrete purchase scene or window. It also owns raw +`Product.SubscriptionInfo.Status` interpretation for renewal, grace-period, +and billing-retry UI, and `PurchaseIntent.intents` handling for purchases that +begin outside the app. -> Important: StoreKit delivery is at least once. Make the injected transaction -> handler durably idempotent using transaction identity and the business event -> it applies. Do not call StoreKit `finish()` from the handler. +> Important: StoreTransactionKit exposes an at-least-once handler-delivery +> contract. Make the injected transaction handler durably idempotent using +> transaction identity and the business event it applies. Purchase and +> revocation revisions are distinct business events. Do not call StoreKit +> `finish()` or call back into the same store from the handler. ## Topics ### Creating a store -- ``Store`` +- ``TransactionStore`` - ``StoreEntitlements`` ### Processing purchases @@ -44,5 +50,6 @@ the concrete purchase scene or window. ### Diagnosing lifecycle and background work - ``StoreTransactionError`` +- ``StoreTransactionVerificationError`` - ``StoreTransactionBackgroundFailure`` - ``StoreTransactionOperation`` From b71084e2d5d65ba548b7691d59c14e03617baa29 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:20:39 +0900 Subject: [PATCH 05/17] fix(storekit): reconcile every unfinished transaction Process verified unfinished deliveries independently of entitlement membership or product type. Report stable unverified deliveries once, keep failed revisions retryable, and document the readiness contract. --- README.md | 7 + .../CurrentEntitlementReconciler.swift | 30 ++-- .../StoreTransactionFailure.swift | 2 +- .../StoreTransactionKit.md | 6 + .../TransactionStore.swift | 14 +- .../ReconciliationFixedPointTests.swift | 138 ++++++++++++++++++ .../StoreTransactionSessionTests.swift | 51 ------- 7 files changed, 183 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 80237be..5dd4bbf 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,13 @@ func makeStore( changes during initialization. Retain one instance in the application's process-lifetime composition. +Startup and each entitlement refresh reconcile `Transaction.unfinished` +before publishing entitlement state. Every verified delivery is durably +handled, including consumables. If the handler fails, startup or the refresh +fails, the transaction remains unfinished, and a later refresh retries it. +An unverified unfinished delivery is sent to `reportFailure` with source +`.unfinished`. + `activeEntitlements` contains the app-defined identifiers represented by StoreKit's current entitlements. It is `nil` while the initial entitlement query is unresolved and becomes a non-`nil` empty set when no known diff --git a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift index 3090bb8..a00b6cd 100644 --- a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift +++ b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift @@ -32,20 +32,14 @@ package final class CurrentEntitlementReconciler: Sendable { while true { let result = try await currentEntitlements() - let currentRevisions = Set( - result.snapshots.map { Data($0.jwsRepresentation.utf8) } - ) var iterationRevisions: Set = [] var acceptedTransactions: [AcceptedTransaction] = [] + var unfinishedVerificationFailures: [any Error] = [] for delivery in await queryUnfinished() { switch delivery { case .verified(let envelope): - let canChangeEntitlements = - envelope.value.productType != .consumable guard - currentRevisions.contains(envelope.revision) - || canChangeEntitlements, !reconciledRevisions.contains(envelope.revision), iterationRevisions.insert(envelope.revision).inserted else { @@ -56,12 +50,15 @@ package final class CurrentEntitlementReconciler: Sendable { snapshot: envelope.value, acceptance: await core.accept(envelope) )) - case .unverified: - continue + case .unverified(let error): + unfinishedVerificationFailures.append(error) } } guard !acceptedTransactions.isEmpty else { + await reportUnfinishedVerificationFailures( + unfinishedVerificationFailures + ) await reportVerificationFailures(result.verificationFailures) return result.snapshots } @@ -71,6 +68,21 @@ package final class CurrentEntitlementReconciler: Sendable { } } + private func reportUnfinishedVerificationFailures( + _ verificationFailures: [any Error] + ) async { + for failure in verificationFailures { + await failures.enqueue( + StoreTransactionBackgroundFailure( + source: .unfinished, + transactionID: nil, + productID: nil, + underlyingError: failure + ) + ) + } + } + private func drain(_ transactions: [AcceptedTransaction]) async throws { var firstError: (any Error)? for transaction in transactions { diff --git a/Sources/StoreTransactionKit/StoreTransactionFailure.swift b/Sources/StoreTransactionKit/StoreTransactionFailure.swift index 0bce554..7875c33 100644 --- a/Sources/StoreTransactionKit/StoreTransactionFailure.swift +++ b/Sources/StoreTransactionKit/StoreTransactionFailure.swift @@ -31,7 +31,7 @@ public struct StoreTransactionBackgroundFailure: Error, Sendable { /// A delivery from `Transaction.updates`. case updates - /// A delivery from `Transaction.unfinished` during startup. + /// A delivery from `Transaction.unfinished` during monitoring or reconciliation. case unfinished /// A refresh requested after background processing completed. diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index d7d745a..c0cf6a1 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -10,6 +10,12 @@ StoreKit can deliver a purchase as a direct `Product.PurchaseResult`, through ``TransactionStore`` normalizes these paths into one verified, FIFO transaction processor and publishes observable current-entitlement state. +Startup and entitlement refreshes reconcile every verified transaction still +reported by `Transaction.unfinished`, including consumables, before publishing +entitlement state. A durable handler failure fails readiness or the refresh and +leaves that transaction available for a later retry. Unverified unfinished +deliveries are reported through ``StoreTransactionBackgroundFailure/Source/unfinished``. + Create one store in the application composition root. Supply an idempotent transaction handler that commits the app's business effect before returning. The app defines a string-backed entitlement identifier type; diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index ea8551c..dec6c8f 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -46,11 +46,12 @@ where } } - /// The error from the initial entitlement query when startup did not reach - /// readiness. + /// The error from the initial readiness attempt. /// - /// Transaction monitoring remains active after a recoverable startup query - /// failure. A later successful entitlement refresh clears this value. + /// Startup includes durable handling of every verified transaction still + /// reported by `Transaction.unfinished`. Transaction monitoring remains + /// active after a recoverable startup failure. A later successful + /// entitlement refresh retries unfinished work and clears this value. public private(set) var startupError: (any Error)? @ObservationIgnored private let sessionID: UUID @@ -138,6 +139,11 @@ where /// Refreshes current entitlements and updates observable store state. /// + /// Before publishing the result, the store durably handles every verified + /// transaction currently reported by `Transaction.unfinished`, including + /// consumables. A handler failure leaves the transaction unfinished, fails + /// this refresh, and allows a later refresh to retry it. + /// /// - Returns: The complete verified entitlement projection. @discardableResult public func refreshEntitlements() async throws -> StoreEntitlements { diff --git a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift index 1f0f1b7..8a852a0 100644 --- a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift +++ b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift @@ -165,8 +165,146 @@ struct ReconciliationFixedPointTests { #expect(await finishes.value() == 1) #expect(await reports.snapshot() == ["stable"]) } + + @Test("a failed unfinished consumable blocks readiness and remains retryable") + func failedUnfinishedConsumableIsRetryable() async throws { + let snapshot = makeSnapshot( + id: 44, + productID: "consumable.tokens", + productType: .consumable, + jws: "unfinished-consumable" + ) + let unfinished = UnfinishedValueSource() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + await unfinished.replace(with: []) + } + ) + await unfinished.replace(with: [delivery]) + + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + } + let failures = FailureReporterDispatcher { failure in + guard failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.productID == snapshot.productID, + failure.underlyingError is TestFailure + else { + await reports.append("unexpected") + return + } + await reports.append("unfinished-\(snapshot.id)") + } + let reconciler = CurrentEntitlementReconciler( + query: { + CurrentEntitlementQueryResult( + snapshots: [], + verificationFailures: [] + ) + }, + queryUnfinished: { await unfinished.read() }, + core: core, + failures: failures + ) + + await #expect(throws: TestFailure.self) { + _ = try await reconciler.query() + } + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 0) + #expect(await reports.snapshot() == ["unfinished-44"]) + + let snapshots = try await reconciler.query() + + #expect(snapshots.isEmpty) + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["unfinished-44"]) + await core.finishInputAndDrain() + await failures.sealAndDrain() + } + + @Test("a persistent unverified unfinished delivery is reported by the stable query once") + func persistentUnverifiedUnfinishedReportsOnce() async throws { + let snapshot = makeSnapshot( + id: 45, + productID: "lifetime.verified", + productType: .nonConsumable, + jws: "unfinished-verification-fixed-point" + ) + let current = EntitlementValueSource([]) + let unfinished = UnfinishedValueSource() + let currentQueryCount = TestSignal() + let unfinishedQueryCount = TestSignal() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + let unverified = StoreTransactionDelivery.unverified( + PersistentUnfinishedVerificationFailure() + ) + let verified = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + await current.replace(with: [snapshot]) + await unfinished.replace(with: [unverified]) + } + ) + await unfinished.replace(with: [unverified, verified]) + + let core = TransactionProcessingCore { _ in + await handlerCalls.send() + } + let failures = FailureReporterDispatcher { failure in + guard failure.source == .unfinished, + failure.transactionID == nil, + failure.productID == nil, + failure.underlyingError + is PersistentUnfinishedVerificationFailure + else { + await reports.append("unexpected") + return + } + await reports.append("unverified") + } + let reconciler = CurrentEntitlementReconciler( + query: { + await currentQueryCount.send() + return CurrentEntitlementQueryResult( + snapshots: await current.read(), + verificationFailures: [] + ) + }, + queryUnfinished: { + await unfinishedQueryCount.send() + return await unfinished.read() + }, + core: core, + failures: failures + ) + + let snapshots = try await reconciler.query() + + #expect(snapshots == [snapshot]) + #expect(await currentQueryCount.value() == 2) + #expect(await unfinishedQueryCount.value() == 2) + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["unverified"]) + await core.finishInputAndDrain() + await failures.sealAndDrain() + } } private struct DiscardedVerificationFailure: Error, Sendable {} private struct StableVerificationFailure: Error, Sendable {} + +private struct PersistentUnfinishedVerificationFailure: Error, Sendable {} diff --git a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift index efa0f0e..333801e 100644 --- a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift @@ -234,57 +234,6 @@ struct StoreTransactionSessionTests { try await session.close() } - @Test("unrelated unfinished work does not block entitlement removal") - func unrelatedUnfinishedDoesNotBlockRemoval() async throws { - let snapshot = makeSnapshot(id: 3, productID: "subscription.plus") - let values = EntitlementValueSource([snapshot]) - let unfinished = UnfinishedValueSource() - let fixture = TestSourceFixture( - currentEntitlements: { await values.read() }, - queryUnfinished: { await unfinished.read() } - ) - fixture.unfinished.finish() - let handlerCalls = TestSignal() - let publications = UInt64Recorder() - let published = TestSignal() - let session = StoreTransactionSession( - source: fixture.source, - handleTransaction: { _ in - await handlerCalls.send() - throw TestFailure() - }, - entitlementsDidChange: { value in - await publications.append(UInt64(value.transactions.count)) - await published.send() - }, - reportFailure: { failure in - Issue.record("Unexpected background failure: \(failure)") - } - ) - - _ = try await session.start() - await values.replace(with: []) - await unfinished.replace( - with: [ - .verified( - makeEnvelope( - snapshot: makeSnapshot( - id: 4, - productID: "consumable.tokens" - ) - ) - ) - ] - ) - - fixture.subscriptionStatusUpdates.yield() - try await published.wait(for: 2) - - #expect(await handlerCalls.value() == 0) - #expect(await publications.snapshot() == [1, 0]) - try await session.close() - } - @Test("revocation handling finishes before entitlement removal is published") func revocationPrecedesRemovalPublication() async throws { let active = makeSnapshot( From d327359ec40d0dcbb372f2cf7f1189fee51ae487 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:21:29 +0900 Subject: [PATCH 06/17] test(storekit): cover immediate purchase outcomes --- .../RuntimeContractTests.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift index 8ae50c8..38991bc 100644 --- a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift @@ -41,6 +41,34 @@ struct RuntimeContractTests { #expect(await waiter.value) } + @Test("immediate purchase outcomes return their semantic values") + func immediatePurchaseOutcomes() async throws { + let fixture = TestSourceFixture() + fixture.unfinished.finish() + let handlerCalls = TestSignal() + let reports = StringRecorder() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + }, + reportFailure: { failure in + await reports.append("\(failure.source)") + } + ) + _ = try await session.start() + + let pending = try await session.process(.pending) + let userCancelled = try await session.process(.userCancelled) + + #expect(pending == .pending) + #expect(userCancelled == .userCancelled) + #expect(await handlerCalls.value() == 0) + #expect(await fixture.entitlementQueryCount.value() == 1) + #expect(await reports.snapshot().isEmpty) + try await session.close() + } + @Test("immediate purchase outcomes honor caller cancellation") func immediatePurchaseOutcomeCancellation() async throws { let fixture = TestSourceFixture() From c61dbe019cd3cbc38115935a713c2d45da8502ff Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:36:53 +0900 Subject: [PATCH 07/17] fix(storekit): preserve failure reporting authority Propagate transaction-processing ownership through entitlement refreshes so background, abandoned, and restore paths do not report the same physical failure twice. Public callers continue to receive the original underlying error. --- .../CurrentEntitlementReconciler.swift | 30 +- .../Runtime/RestoreCoordinator.swift | 14 +- .../Runtime/StoreTransactionPipeline.swift | 9 +- .../Runtime/StoreTransactionRuntime.swift | 20 +- .../StoreTransactionFailure.swift | 25 ++ .../ReconciliationFixedPointTests.swift | 7 +- .../RuntimeContractTests.swift | 414 ++++++++++++++++++ .../StoreTransactionKitTests/StoreTests.swift | 37 ++ .../StoreTransactionSessionTests.swift | 81 ++++ 9 files changed, 617 insertions(+), 20 deletions(-) diff --git a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift index a00b6cd..32a8acd 100644 --- a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift +++ b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift @@ -2,9 +2,17 @@ import Foundation import StoreKit package final class CurrentEntitlementReconciler: Sendable { - private struct AcceptedTransaction: Sendable { + struct AcceptedTransaction: Sendable { let snapshot: StoreTransactionSnapshot let acceptance: ProcessingAcceptance + + init( + snapshot: StoreTransactionSnapshot, + acceptance: ProcessingAcceptance + ) { + self.snapshot = snapshot + self.acceptance = acceptance + } } private let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult @@ -83,23 +91,25 @@ package final class CurrentEntitlementReconciler: Sendable { } } - private func drain(_ transactions: [AcceptedTransaction]) async throws { + func drain(_ transactions: [AcceptedTransaction]) async throws { var firstError: (any Error)? for transaction in transactions { do { _ = try await transaction.acceptance.receipt.terminalValue() } catch { if transaction.acceptance.role == .owner { - await failures.enqueue( - StoreTransactionBackgroundFailure( - source: .unfinished, - transactionID: transaction.snapshot.id, - productID: transaction.snapshot.productID, - underlyingError: error - )) + let failure = StoreTransactionBackgroundFailure( + source: .unfinished, + transactionID: transaction.snapshot.id, + productID: transaction.snapshot.productID, + underlyingError: error + ) + await failures.enqueue(failure) } if firstError == nil { - firstError = error + firstError = StoreTransactionFailureWithReportingOwner( + underlyingError: error + ) } } } diff --git a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift index a119271..5658c73 100644 --- a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift +++ b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift @@ -11,6 +11,16 @@ package struct RestoreReservation: Sendable { package struct RestoreCoordinatorFailure: Error { package let underlyingError: any Error package let reportsWhenAbandoned: Bool + + package init( + propagating error: any Error, + reportsWhenAbandoned: Bool + ) { + let propagation = StoreTransactionFailurePropagation(error) + self.underlyingError = propagation.underlyingError + self.reportsWhenAbandoned = + reportsWhenAbandoned && !propagation.hasReportingOwner + } } package actor RestoreCoordinator { @@ -56,7 +66,7 @@ package actor RestoreCoordinator { id: id, result: .failure( RestoreCoordinatorFailure( - underlyingError: error, + propagating: error, reportsWhenAbandoned: true )) ) @@ -69,7 +79,7 @@ package actor RestoreCoordinator { } catch { result = .failure( RestoreCoordinatorFailure( - underlyingError: error, + propagating: error, reportsWhenAbandoned: refresh.role == .owner )) } diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift index 713777a..c79e7ca 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift @@ -81,13 +81,15 @@ package final class StoreTransactionPipeline: Sendable { do { _ = try await refresh.receipt.terminalValue() } catch { + let propagation = StoreTransactionFailurePropagation(error) + guard !propagation.hasReportingOwner else { return } guard refresh.role == .owner else { return } await failures.enqueue( StoreTransactionBackgroundFailure( source: .entitlementRefresh, transactionID: snapshot?.id, productID: snapshot?.productID, - underlyingError: error + underlyingError: propagation.underlyingError )) } } @@ -97,15 +99,16 @@ package final class StoreTransactionPipeline: Sendable { do { _ = try await refresh.receipt.terminalValue() } catch { + let propagation = StoreTransactionFailurePropagation(error) + guard !propagation.hasReportingOwner else { return } guard refresh.role == .owner else { return } await failures.enqueue( StoreTransactionBackgroundFailure( source: .entitlementRefresh, transactionID: nil, productID: nil, - underlyingError: error + underlyingError: propagation.underlyingError )) } } - } diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index 0ba5aa8..0471033 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -3,6 +3,16 @@ import StoreKit private struct DirectOperationFailure: Error { let underlyingError: any Error let reportsWhenAbandoned: Bool + + init( + propagating error: any Error, + reportsWhenAbandoned: Bool + ) { + let propagation = StoreTransactionFailurePropagation(error) + self.underlyingError = propagation.underlyingError + self.reportsWhenAbandoned = + reportsWhenAbandoned && !propagation.hasReportingOwner + } } package final class StoreTransactionRuntime: Sendable { @@ -115,6 +125,8 @@ package final class StoreTransactionRuntime: Sendable { ) } catch is ProcessingReceiptWaiterCancellation { throw CancellationError() + } catch let owned as StoreTransactionFailureWithReportingOwner { + throw owned.underlyingError } } @@ -184,7 +196,7 @@ package final class StoreTransactionRuntime: Sendable { } catch { operationReceipt.fail( DirectOperationFailure( - underlyingError: error, + propagating: error, reportsWhenAbandoned: accepted.acceptance.role == .owner ) @@ -199,7 +211,7 @@ package final class StoreTransactionRuntime: Sendable { } catch { operationReceipt.fail( DirectOperationFailure( - underlyingError: error, + propagating: error, reportsWhenAbandoned: refresh.role == .owner ) ) @@ -229,7 +241,7 @@ package final class StoreTransactionRuntime: Sendable { } catch { operationReceipt.fail( DirectOperationFailure( - underlyingError: error, + propagating: error, reportsWhenAbandoned: refresh.role == .owner ) ) @@ -283,7 +295,7 @@ package final class StoreTransactionRuntime: Sendable { } catch let failure as RestoreCoordinatorFailure { operationReceipt.fail( DirectOperationFailure( - underlyingError: failure.underlyingError, + propagating: failure.underlyingError, reportsWhenAbandoned: restore.role == .owner && failure.reportsWhenAbandoned diff --git a/Sources/StoreTransactionKit/StoreTransactionFailure.swift b/Sources/StoreTransactionKit/StoreTransactionFailure.swift index 7875c33..2fd2bf6 100644 --- a/Sources/StoreTransactionKit/StoreTransactionFailure.swift +++ b/Sources/StoreTransactionKit/StoreTransactionFailure.swift @@ -69,6 +69,31 @@ public struct StoreTransactionBackgroundFailure: Error, Sendable { } } +package struct StoreTransactionFailureWithReportingOwner: Error, Sendable { + package let underlyingError: any Error + + package init(underlyingError: any Error) { + self.underlyingError = underlyingError + } +} + +package struct StoreTransactionFailurePropagation: Sendable { + package let underlyingError: any Error + package let hasReportingOwner: Bool + + package init(_ error: any Error) { + if let owned = + error as? StoreTransactionFailureWithReportingOwner + { + self.underlyingError = owned.underlyingError + self.hasReportingOwner = true + } else { + self.underlyingError = error + self.hasReportingOwner = false + } + } +} + /// An error caused by using a store outside its documented lifecycle. public enum StoreTransactionError: Error, Sendable, Hashable { /// The store has begun its shared close operation and accepts no new work. diff --git a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift index 8a852a0..96bf802 100644 --- a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift +++ b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift @@ -215,8 +215,13 @@ struct ReconciliationFixedPointTests { failures: failures ) - await #expect(throws: TestFailure.self) { + do { _ = try await reconciler.query() + Issue.record("A failed unfinished transaction unexpectedly reconciled.") + } catch let owned as StoreTransactionFailureWithReportingOwner { + #expect(owned.underlyingError is TestFailure) + } catch { + Issue.record("Unexpected reconciliation error: \(error)") } #expect(await handlerCalls.value() == 1) #expect(await finishes.value() == 0) diff --git a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift index 38991bc..532375a 100644 --- a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift @@ -283,6 +283,337 @@ struct RuntimeContractTests { #expect(await reports.snapshot() == ["abandoned-refresh"]) } + @Test("an attached refresh receives the reported underlying failure and can retry") + func attachedRefreshUnwrapsReportedFailure() async throws { + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() } + ) + fixture.unfinished.finish() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 26, + productID: "consumable.refresh", + productType: .consumable + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-26") + } else { + await reports.append("unexpected") + } + } + ) + _ = try await session.start() + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + await unfinished.replace(with: []) + }) + ] + ) + + await #expect(throws: TestFailure.self) { + _ = try await session.currentEntitlements() + } + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 0) + #expect(await reports.snapshot() == ["unfinished-26"]) + + let entitlements = try await session.currentEntitlements() + + #expect(entitlements.transactions.isEmpty) + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["unfinished-26"]) + try await session.close() + } + + @Test("a direct process unwraps a reported refresh failure and can retry") + func directProcessUnwrapsReportedRefreshFailure() async throws { + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() } + ) + fixture.unfinished.finish() + let handled = UInt64Recorder() + let consumableAttempts = TestSignal() + let directFinishes = TestSignal() + let consumableFinishes = TestSignal() + let reports = StringRecorder() + let direct = makeSnapshot( + id: 27, + productID: "lifetime.direct", + productType: .nonConsumable + ) + let consumable = makeSnapshot( + id: 28, + productID: "consumable.process", + productType: .consumable + ) + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + handleTransaction: { snapshot in + await handled.append(snapshot.id) + if snapshot.id == consumable.id { + await consumableAttempts.send() + if await consumableAttempts.value() == 1 { + throw TestFailure() + } + } + }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == consumable.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-28") + } else { + await reports.append("unexpected") + } + } + ) + _ = try await runtime.readiness() + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: consumable) { + await consumableFinishes.send() + await unfinished.replace(with: []) + }) + ] + ) + let directDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: direct) { + await directFinishes.send() + } + ) + + let firstLeases = try #require(runtime.beginOperation()) + await #expect(throws: TestFailure.self) { + _ = try await runtime.process( + directDelivery, + leases: firstLeases + ) + } + #expect(await handled.snapshot() == [direct.id, consumable.id]) + #expect(await directFinishes.value() == 1) + #expect(await consumableFinishes.value() == 0) + #expect(await reports.snapshot() == ["unfinished-28"]) + + let secondLeases = try #require(runtime.beginOperation()) + let outcome = try await runtime.process( + directDelivery, + leases: secondLeases + ) + + #expect(outcome == .completed(direct)) + #expect( + await handled.snapshot() == [ + direct.id, consumable.id, consumable.id, + ] + ) + #expect(await directFinishes.value() == 1) + #expect(await consumableFinishes.value() == 1) + #expect(await reports.snapshot() == ["unfinished-28"]) + await runtime.close() + } + + @Test("restore unwraps a reported refresh failure and can retry") + func restoreUnwrapsReportedRefreshFailure() async throws { + let unfinished = UnfinishedValueSource() + let synchronizations = TestSignal() + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() }, + synchronize: { await synchronizations.send() } + ) + fixture.unfinished.finish() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 29, + productID: "consumable.restore", + productType: .consumable + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-29") + } else { + await reports.append("unexpected") + } + } + ) + _ = try await session.start() + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + await unfinished.replace(with: []) + }) + ] + ) + + await #expect(throws: TestFailure.self) { + _ = try await session.restorePurchases() + } + #expect(await synchronizations.value() == 1) + #expect(await reports.snapshot() == ["unfinished-29"]) + + let entitlements = try await session.restorePurchases() + + #expect(entitlements.transactions.isEmpty) + #expect(await synchronizations.value() == 2) + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["unfinished-29"]) + try await session.close() + } + + @Test("an abandoned refresh does not report an owned reconciliation failure twice") + func abandonedRefreshDoesNotDuplicateReportedFailure() async throws { + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() } + ) + fixture.unfinished.finish() + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let reported = TestSignal() + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 30, + productID: "consumable.abandoned", + productType: .consumable + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerStarted.send() + try await handlerGate.wait() + throw TestFailure() + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-30") + } else { + await reports.append("unexpected") + } + await reported.send() + } + ) + _ = try await session.start() + await unfinished.replace( + with: [.verified(makeEnvelope(snapshot: snapshot))] + ) + + let refresh = Task { + try await session.currentEntitlements() + } + try await handlerStarted.wait(for: 1) + refresh.cancel() + await #expect(throws: CancellationError.self) { + _ = try await refresh.value + } + + await handlerGate.open() + try await reported.wait(for: 1) + try await session.close() + + #expect(await reports.snapshot() == ["unfinished-30"]) + } + + @Test("an abandoned restore does not report an owned reconciliation failure twice") + func abandonedRestoreDoesNotDuplicateReportedFailure() async throws { + let unfinished = UnfinishedValueSource() + let synchronizations = TestSignal() + let fixture = TestSourceFixture( + queryUnfinished: { await unfinished.read() }, + synchronize: { await synchronizations.send() } + ) + fixture.unfinished.finish() + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let reported = TestSignal() + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 32, + productID: "consumable.abandoned-restore", + productType: .consumable + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerStarted.send() + try await handlerGate.wait() + throw TestFailure() + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-32") + } else { + await reports.append("unexpected") + } + await reported.send() + } + ) + _ = try await session.start() + await unfinished.replace( + with: [.verified(makeEnvelope(snapshot: snapshot))] + ) + + let restore = Task { + try await session.restorePurchases() + } + try await handlerStarted.wait(for: 1) + restore.cancel() + await #expect(throws: CancellationError.self) { + _ = try await restore.value + } + + await handlerGate.open() + try await reported.wait(for: 1) + try await session.close() + + #expect(await synchronizations.value() == 1) + #expect(await reports.snapshot() == ["unfinished-32"]) + } + @Test("history is newest first and retains revoked transactions") func historyOrderAndMembership() async throws { let sharedDate = Date(timeIntervalSince1970: 100) @@ -359,6 +690,89 @@ struct RuntimeContractTests { #expect(await reports.snapshot() == ["entitlementRefresh-19-product"]) } + @Test("an update owner prevents an observer refresh from reporting the same failure") + func observerRefreshUsesTransactionReportingOwner() async throws { + let handlerStarted = TestSignal() + let handlerGate = TestGate() + let reports = StringRecorder() + let snapshot = makeSnapshot( + id: 31, + productID: "consumable.observer", + productType: .consumable + ) + let envelope = makeEnvelope(snapshot: snapshot) + let core = TransactionProcessingCore { _ in + await handlerStarted.send() + try await handlerGate.wait() + throw TestFailure() + } + let failures = FailureReporterDispatcher { failure in + switch failure.source { + case .updates where failure.underlyingError is TestFailure: + await reports.append("updates") + case .unfinished, .entitlementRefresh, + .abandonedDirectOperation: + await reports.append("duplicate") + default: + await reports.append("unexpected") + } + } + let reconciler = CurrentEntitlementReconciler( + query: { + CurrentEntitlementQueryResult( + snapshots: [], + verificationFailures: [] + ) + }, + queryUnfinished: { [] }, + core: core, + failures: failures + ) + + let owner = await core.accept(envelope) + try await handlerStarted.wait(for: 1) + let observer = await core.accept(envelope) + #expect(owner.role == .owner) + #expect(observer.role == .inFlightObserver) + + let entitlements = EntitlementRefreshCoordinator( + query: { + try await reconciler.drain([ + CurrentEntitlementReconciler.AcceptedTransaction( + snapshot: snapshot, + acceptance: observer + ) + ]) + return [] + }, + didChange: { _ in } + ) + let pipeline = StoreTransactionPipeline( + core: core, + entitlements: entitlements, + failures: failures + ) + let update = Task { + await pipeline.processAcceptedBackground( + snapshot: snapshot, + acceptance: owner, + source: .updates + ) + } + let subscriptionRefresh = Task { + await pipeline.refreshEntitlements() + } + + await handlerGate.open() + await update.value + await subscriptionRefresh.value + + #expect(await reports.snapshot() == ["updates"]) + await core.finishInputAndDrain() + await entitlements.sealAndDrain() + await failures.sealAndDrain() + } + @Test("reconciliation requeries after handling a newly unfinished revision") func reconciliationRequeriesAfterUnfinishedHandling() async throws { let entitlementQueryCount = TestSignal() diff --git a/Tests/StoreTransactionKitTests/StoreTests.swift b/Tests/StoreTransactionKitTests/StoreTests.swift index 6b6850b..e44a9ec 100644 --- a/Tests/StoreTransactionKitTests/StoreTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTests.swift @@ -114,6 +114,43 @@ struct StoreTests { try await store.close() } + @Test("startup exposes the underlying unfinished handler failure") + func startupUnwrapsReportedHandlerFailure() async throws { + let snapshot = makeSnapshot( + id: 8, + productID: "consumable.startup", + productType: .consumable + ) + let fixture = TestSourceFixture( + queryUnfinished: { + [.verified(makeEnvelope(snapshot: snapshot))] + } + ) + fixture.unfinished.finish() + let reports = StringRecorder() + let store = TransactionStore( + source: fixture.source, + handleTransaction: { _ in throw TestFailure() }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-8") + } else { + await reports.append("unexpected") + } + } + ) + + await store.waitForStartup() + + #expect(store.startupError is TestFailure) + #expect(store.entitlements == nil) + #expect(await reports.snapshot() == ["unfinished-8"]) + try await store.close() + } + @Test("a stale startup failure cannot replace newer entitlement readiness") func newerReadinessWinsStartupFailureRace() async throws { let snapshot = makeSnapshot( diff --git a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift index 333801e..bec938d 100644 --- a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift @@ -234,6 +234,87 @@ struct StoreTransactionSessionTests { try await session.close() } + @Test("an unfinished consumable failure blocks publication and reports once") + func unfinishedConsumableFailureBlocksPublication() async throws { + let active = makeSnapshot( + id: 3, + productID: "subscription.plus", + productType: .autoRenewable + ) + let consumable = makeSnapshot( + id: 4, + productID: "consumable.tokens", + productType: .consumable + ) + let values = EntitlementValueSource([active]) + let unfinished = UnfinishedValueSource() + let fixture = TestSourceFixture( + currentEntitlements: { await values.read() }, + queryUnfinished: { await unfinished.read() } + ) + fixture.unfinished.finish() + let handlerCalls = TestSignal() + let finishes = TestSignal() + let publications = UInt64Recorder() + let published = TestSignal() + let reports = StringRecorder() + let reported = TestSignal() + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { snapshot in + #expect(snapshot.id == consumable.id) + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + }, + entitlementsDidChange: { value in + await publications.append(UInt64(value.transactions.count)) + await published.send() + }, + reportFailure: { failure in + if failure.source == .unfinished, + failure.transactionID == consumable.id, + failure.productID == consumable.productID, + failure.underlyingError is TestFailure + { + await reports.append("unfinished-4") + } else { + await reports.append("unexpected") + } + await reported.send() + } + ) + + _ = try await session.start() + await values.replace(with: []) + await unfinished.replace( + with: [ + .verified( + makeEnvelope(snapshot: consumable) { + await finishes.send() + await unfinished.replace(with: []) + }) + ] + ) + + fixture.subscriptionStatusUpdates.yield() + try await reported.wait(for: 1) + + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 0) + #expect(await publications.snapshot() == [1]) + + fixture.subscriptionStatusUpdates.yield() + try await published.wait(for: 2) + + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 1) + #expect(await publications.snapshot() == [1, 0]) + #expect(await reports.snapshot() == ["unfinished-4"]) + try await session.close() + } + @Test("revocation handling finishes before entitlement removal is published") func revocationPrecedesRemovalPublication() async throws { let active = makeSnapshot( From dc929733e61d57950a8c6d65a51075b1dc86120e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:38:21 +0900 Subject: [PATCH 08/17] test(storekit): fail fast when configuration is unavailable --- .../StoreTransactionKitIntegrationTests.swift | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift index ac6d4c1..7dd73c5 100644 --- a/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift +++ b/Tools/TestApp/StoreTransactionKitIntegrationTests/StoreTransactionKitIntegrationTests.swift @@ -16,7 +16,7 @@ private enum Entitlement: String, Hashable, Sendable { struct StoreTransactionKitIntegrationTests { @Test func externalPurchaseIsHandledAndFinishedBeforePublication() async throws { - let session = try makeTestSession() + let session = try await makeTestSession() defer { session.resetToDefaultState() session.clearTransactions() @@ -158,7 +158,7 @@ struct StoreTransactionKitIntegrationTests { @Test func unfinishedPurchaseReplaysUntilDurableHandlingSucceeds() async throws { - let session = try makeTestSession() + let session = try await makeTestSession() defer { session.resetToDefaultState() session.clearTransactions() @@ -243,7 +243,7 @@ struct StoreTransactionKitIntegrationTests { @Test func unverifiedUpdateIsReportedAndRemainsUnfinished() async throws { - let session = try makeTestSession() + let session = try await makeTestSession() defer { session.resetToDefaultState() session.clearTransactions() @@ -385,7 +385,7 @@ struct StoreTransactionKitIntegrationTests { @Test func renewalWaitsForDurableHandlingBeforeStatusPublication() async throws { - let session = try makeTestSession() + let session = try await makeTestSession() defer { session.resetToDefaultState() session.clearTransactions() @@ -623,7 +623,7 @@ private final class TestContext { preexistingPurchaseOptions: Set = [], expectedEntitlements: Set? = nil ) async throws { - let session = try makeTestSession() + let session = try await makeTestSession() self.session = session if let preexistingSubscription { _ = try await session.buyProduct( @@ -865,7 +865,7 @@ private actor TestSignal { private struct DurableHandlingFailure: Error {} -private func makeTestSession() throws -> SKTestSession { +private func makeTestSession() async throws -> SKTestSession { let configuration = try #require( Bundle(for: BundleToken.self).url( forResource: "StoreKitTest", @@ -877,7 +877,26 @@ private func makeTestSession() throws -> SKTestSession { session.timeRate = .realTime session.resetToDefaultState() session.clearTransactions() + try await StoreKitTestEnvironment.requireAvailable() return session } +@MainActor +private enum StoreKitTestEnvironment { + private static var isAvailable = false + + static func requireAvailable() async throws { + guard !isAvailable else { + return + } + _ = try #require( + try await Product.products( + for: [Entitlement.lifetime.rawValue] + ).first, + "The StoreKit test configuration is not active." + ) + isAvailable = true + } +} + private final class BundleToken {} From 27723b0295e7bcbc35948ccd134465d0c19ae5e5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:38:41 +0900 Subject: [PATCH 09/17] ci(storekit): activate integration test configuration --- .github/workflows/ci.yml | 2 +- .../xcschemes/StoreTransactionKitIntegrationTests.xcscheme | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce594c2..249eb1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: ios-tests: name: Package Tests (iOS Simulator) runs-on: macos-26 - timeout-minutes: 20 + timeout-minutes: 10 env: XCODE_VERSION: "26.5" steps: diff --git a/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme index a91f1d2..5b3299f 100644 --- a/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme +++ b/Tools/TestApp/StoreTransactionKitTestApp.xcodeproj/xcshareddata/xcschemes/StoreTransactionKitIntegrationTests.xcscheme @@ -64,6 +64,9 @@ ReferencedContainer = "container:StoreTransactionKitTestApp.xcodeproj"> + + Date: Fri, 17 Jul 2026 10:29:35 +0900 Subject: [PATCH 10/17] fix(storekit): preserve startup retry boundaries Retain failed transaction receipts for the complete physical startup attempt so StoreKit launch replay and unfinished reconciliation cannot execute the same revision twice. Caller cancellation no longer advances readiness gates before reconciliation terminates. --- .../CurrentEntitlementReconciler.swift | 7 +- .../EntitlementRefreshCoordinator.swift | 31 +++- .../TransactionProcessingCore.swift | 31 ++++ .../Runtime/RestoreCoordinator.swift | 8 +- .../Runtime/StoreTransactionPipeline.swift | 24 ++- .../Runtime/StoreTransactionRuntime.swift | 71 +++++--- .../LiveStoreTransactionSource.swift | 5 - .../StoreTransactionSource.swift | 9 - .../CompletedDeliveryRefreshTests.swift | 2 +- .../EntitlementRefreshCoordinatorTests.swift | 70 +++++++- .../LifecycleResidualTests.swift | 3 +- .../ReconciliationFixedPointTests.swift | 20 ++- .../RuntimeContractTests.swift | 28 +-- .../StoreTransactionKitTests/StoreTests.swift | 80 +++++---- .../StoreTransactionSessionTests.swift | 163 ++++++++++++++++-- .../TestSupport.swift | 8 - .../TransactionProcessingCoreTests.swift | 2 + 17 files changed, 429 insertions(+), 133 deletions(-) diff --git a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift index 32a8acd..5431c5a 100644 --- a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift +++ b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift @@ -35,7 +35,12 @@ package final class CurrentEntitlementReconciler: Sendable { self.failures = failures } - package func query() async throws -> [StoreTransactionSnapshot] { + package func query( + retryFailedTransactions: Bool + ) async throws -> [StoreTransactionSnapshot] { + if retryFailedTransactions { + await core.beginRetryAttempt() + } var reconciledRevisions: Set = [] while true { diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift index c1481d2..0d53f1a 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift @@ -13,11 +13,12 @@ package struct EntitlementRefreshReservation: Sendable { package actor EntitlementRefreshCoordinator { private struct PendingReservation: Sendable { let token: UInt64 + let retryFailedTransactions: Bool let receipt: ProcessingReceipt } private let sessionID: UUID - private let query: @Sendable () async throws -> [StoreTransactionSnapshot] + private let query: @Sendable (Bool) async throws -> [StoreTransactionSnapshot] private let didChange: @Sendable (StoreEntitlements) async -> Void private var nextToken: UInt64 = 0 private var current: StoreEntitlements? @@ -28,7 +29,7 @@ package actor EntitlementRefreshCoordinator { package init( sessionID: UUID = UUID(), query: - @escaping @Sendable () async throws + @escaping @Sendable (Bool) async throws -> [StoreTransactionSnapshot], didChange: @escaping @Sendable (StoreEntitlements) async -> Void ) { @@ -37,7 +38,9 @@ package actor EntitlementRefreshCoordinator { self.didChange = didChange } - package func reserve() -> EntitlementRefreshReservation { + package func reserve( + retryFailedTransactions: Bool = true + ) -> EntitlementRefreshReservation { guard acceptsReservations else { return EntitlementRefreshReservation( receipt: .failed( @@ -49,10 +52,15 @@ package actor EntitlementRefreshCoordinator { precondition(nextToken < .max) nextToken += 1 let role: EntitlementRefreshReservation.Role = - pending.isEmpty ? .owner : .observer + pending.last?.retryFailedTransactions == retryFailedTransactions + ? .observer : .owner let receipt = ProcessingReceipt() pending.append( - PendingReservation(token: nextToken, receipt: receipt) + PendingReservation( + token: nextToken, + retryFailedTransactions: retryFailedTransactions, + receipt: receipt + ) ) startWorkerIfNeeded() return EntitlementRefreshReservation(receipt: receipt, role: role) @@ -75,10 +83,17 @@ package actor EntitlementRefreshCoordinator { private func runQueries() async { while !pending.isEmpty { - let reservations = pending - pending.removeAll(keepingCapacity: true) + let retryFailedTransactions = + pending[0].retryFailedTransactions + let end = + pending.firstIndex { + $0.retryFailedTransactions != retryFailedTransactions + } ?? pending.endIndex + let reservations = Array(pending[..: Sendable { package enum Role: Equatable, Sendable { case owner case inFlightObserver + case failedObserver case completedObserver } @@ -21,9 +22,11 @@ package actor TransactionProcessingCore { private let handle: @Sendable (Value) async throws -> Void private var queue: [QueuedOperation] = [] private var inFlight: [Data: ProcessingReceipt] = [:] + private var failed: [Data: ProcessingReceipt] = [:] private var completed = CompletedRevisionCache() private var worker: Task? private var acceptsInput = true + private var initialAttemptCompleted = false package init( sessionID: UUID = UUID(), @@ -54,6 +57,12 @@ package actor TransactionProcessingCore { role: .inFlightObserver ) } + if let receipt = failed[envelope.revision] { + return ProcessingAcceptance( + receipt: receipt, + role: .failedObserver + ) + } let receipt = ProcessingReceipt() inFlight[envelope.revision] = receipt @@ -62,12 +71,33 @@ package actor TransactionProcessingCore { return ProcessingAcceptance(receipt: receipt, role: .owner) } + package func retryFailedTransactionsInNewAttempt() -> Bool { + initialAttemptCompleted + } + + package func beginTransactionAttempt() -> Bool { + guard initialAttemptCompleted else { + return false + } + failed.removeAll(keepingCapacity: true) + return true + } + + package func beginRetryAttempt() { + failed.removeAll(keepingCapacity: true) + } + + package func completeInitialAttempt() { + initialAttemptCompleted = true + } + package func finishInputAndDrain() async { acceptsInput = false let activeWorker = worker await activeWorker?.value precondition(queue.isEmpty) precondition(inFlight.isEmpty) + failed.removeAll(keepingCapacity: false) } private func startWorkerIfNeeded() { @@ -96,6 +126,7 @@ package actor TransactionProcessingCore { operation.receipt.succeed(operation.envelope.value) } catch { inFlight.removeValue(forKey: operation.envelope.revision) + failed[operation.envelope.revision] = operation.receipt operation.receipt.fail(error) } } diff --git a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift index 5658c73..0191694 100644 --- a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift +++ b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift @@ -43,7 +43,9 @@ package actor RestoreCoordinator { self.entitlements = entitlements } - package func reserve() -> RestoreReservation { + package func reserve( + retryFailedTransactions: Bool = true + ) -> RestoreReservation { if let inFlight { return RestoreReservation( receipt: inFlight.receipt, @@ -73,7 +75,9 @@ package actor RestoreCoordinator { return } - let refresh = await entitlements.reserve() + let refresh = await entitlements.reserve( + retryFailedTransactions: retryFailedTransactions + ) do { result = .success(try await refresh.receipt.terminalValue()) } catch { diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift index c79e7ca..16fb67e 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift @@ -17,11 +17,17 @@ package final class StoreTransactionPipeline: Sendable { _ delivery: StoreTransactionDelivery ) async throws -> ( snapshot: StoreTransactionSnapshot, - acceptance: ProcessingAcceptance + acceptance: ProcessingAcceptance, + retryFailedTransactions: Bool ) { switch delivery { case .verified(let envelope): - return (envelope.value, await core.accept(envelope)) + let retryFailedTransactions = await core.beginTransactionAttempt() + return ( + envelope.value, + await core.accept(envelope), + retryFailedTransactions + ) case .unverified(let error): throw error } @@ -33,10 +39,12 @@ package final class StoreTransactionPipeline: Sendable { ) async { let snapshot: StoreTransactionSnapshot? let acceptance: ProcessingAcceptance + let retryFailedTransactions: Bool do { let accepted = try await accept(delivery) snapshot = accepted.snapshot acceptance = accepted.acceptance + retryFailedTransactions = accepted.retryFailedTransactions } catch { await failures.enqueue( StoreTransactionBackgroundFailure( @@ -51,6 +59,7 @@ package final class StoreTransactionPipeline: Sendable { await processAcceptedBackground( snapshot: snapshot, acceptance: acceptance, + retryFailedTransactions: retryFailedTransactions, source: source ) } @@ -58,6 +67,7 @@ package final class StoreTransactionPipeline: Sendable { package func processAcceptedBackground( snapshot: StoreTransactionSnapshot?, acceptance: ProcessingAcceptance, + retryFailedTransactions: Bool = true, source: StoreTransactionBackgroundFailure.Source ) async { do { @@ -77,7 +87,9 @@ package final class StoreTransactionPipeline: Sendable { if case .inFlightObserver = acceptance.role { return } - let refresh = await entitlements.reserve() + let refresh = await entitlements.reserve( + retryFailedTransactions: retryFailedTransactions + ) do { _ = try await refresh.receipt.terminalValue() } catch { @@ -95,7 +107,11 @@ package final class StoreTransactionPipeline: Sendable { } package func refreshEntitlements() async { - let refresh = await entitlements.reserve() + let retryFailedTransactions = + await core.retryFailedTransactionsInNewAttempt() + let refresh = await entitlements.reserve( + retryFailedTransactions: retryFailedTransactions + ) do { _ = try await refresh.receipt.terminalValue() } catch { diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index 0471033..8535419 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -28,7 +28,6 @@ package final class StoreTransactionRuntime: Sendable { private let producerCancellation = TaskCancellationBag() private let finiteTasks = TaskCompletionBag() private let updatesTask: Task - private let unfinishedTask: Task private let subscriptionStatusTask: Task package init( @@ -59,7 +58,11 @@ package final class StoreTransactionRuntime: Sendable { ) let entitlements = EntitlementRefreshCoordinator( sessionID: sessionID, - query: currentEntitlements.query, + query: { retryFailedTransactions in + try await currentEntitlements.query( + retryFailedTransactions: retryFailedTransactions + ) + }, didChange: entitlementsDidChange ) let pipeline = StoreTransactionPipeline( @@ -84,11 +87,6 @@ package final class StoreTransactionRuntime: Sendable { await pipeline.processBackground(delivery, source: .updates) } } - self.unfinishedTask = Task.detached { - await source.runUnfinished { delivery in - await pipeline.processBackground(delivery, source: .unfinished) - } - } self.subscriptionStatusTask = Task.detached { await source.runSubscriptionStatusUpdates { do { @@ -104,7 +102,6 @@ package final class StoreTransactionRuntime: Sendable { } } producerCancellation.insert(updatesTask) - producerCancellation.insert(unfinishedTask) producerCancellation.insert(subscriptionStatusTask) } @@ -113,20 +110,41 @@ package final class StoreTransactionRuntime: Sendable { } package func readiness() async throws -> StoreTransactionReadiness { - defer { + let reservation = await entitlements.reserve( + retryFailedTransactions: false + ) + let completion = ProcessingReceipt() + let core = core + let subscriptionStatusReadiness = subscriptionStatusReadiness + let readinessLease = readinessLease + let task = Task { + let result: Result + do { + result = .success( + StoreTransactionReadiness( + entitlements: try await reservation.receipt.terminalValue() + )) + } catch let owned as StoreTransactionFailureWithReportingOwner { + result = .failure(owned.underlyingError) + } catch { + result = .failure(error) + } + await core.completeInitialAttempt() subscriptionStatusReadiness.succeed(()) readinessLease.end() + switch result { + case .success(let readiness): + completion.succeed(readiness) + case .failure(let error): + completion.fail(error) + } } - await unfinishedTask.value - let reservation = await entitlements.reserve() + finiteTasks.insert(task) + do { - return StoreTransactionReadiness( - entitlements: try await reservation.receipt.value() - ) + return try await completion.value() } catch is ProcessingReceiptWaiterCancellation { throw CancellationError() - } catch let owned as StoreTransactionFailureWithReportingOwner { - throw owned.underlyingError } } @@ -176,7 +194,8 @@ package final class StoreTransactionRuntime: Sendable { let accepted: ( snapshot: StoreTransactionSnapshot, - acceptance: ProcessingAcceptance + acceptance: ProcessingAcceptance, + retryFailedTransactions: Bool ) do { accepted = try await pipeline.accept(delivery) @@ -204,7 +223,10 @@ package final class StoreTransactionRuntime: Sendable { return } - let refresh = await entitlements.reserve() + let refresh = await entitlements.reserve( + retryFailedTransactions: + accepted.retryFailedTransactions + ) do { _ = try await refresh.receipt.terminalValue() operationReceipt.succeed(snapshot) @@ -229,11 +251,15 @@ package final class StoreTransactionRuntime: Sendable { package func currentEntitlements( leases: FiniteOperationLeases ) async throws -> StoreEntitlements { + let retryFailedTransactions = + await core.retryFailedTransactionsInNewAttempt() let operationReceipt = ProcessingReceipt() let entitlements = entitlements let task = Task { defer { leases.work.end() } - let refresh = await entitlements.reserve() + let refresh = await entitlements.reserve( + retryFailedTransactions: retryFailedTransactions + ) do { operationReceipt.succeed( try await refresh.receipt.terminalValue() @@ -284,7 +310,11 @@ package final class StoreTransactionRuntime: Sendable { package func restorePurchases( leases: FiniteOperationLeases ) async throws -> StoreEntitlements { - let restore = await restoreCoordinator.reserve() + let retryFailedTransactions = + await core.retryFailedTransactionsInNewAttempt() + let restore = await restoreCoordinator.reserve( + retryFailedTransactions: retryFailedTransactions + ) let operationReceipt = ProcessingReceipt() let task = Task { defer { leases.work.end() } @@ -319,7 +349,6 @@ package final class StoreTransactionRuntime: Sendable { package func close() async { producerCancellation.cancel() await updatesTask.value - await unfinishedTask.value await subscriptionStatusTask.value await operations.stopAdmissionAndWait() await finiteTasks.waitForAll() diff --git a/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift index 4c55dc2..a7f82e6 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/LiveStoreTransactionSource.swift @@ -7,11 +7,6 @@ package extension StoreTransactionSource { await consume(LiveTransactionAdapter.delivery(result)) } }, - runUnfinished: { consume in - for await result in Transaction.unfinished { - await consume(LiveTransactionAdapter.delivery(result)) - } - }, runSubscriptionStatusUpdates: { consume in for await _ in Product.SubscriptionInfo.Status.updates { await consume() diff --git a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift index a1fe73c..f9b3591 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift @@ -23,10 +23,6 @@ package struct StoreTransactionSource: Sendable { @Sendable ( @Sendable (StoreTransactionDelivery) async -> Void ) async -> Void - package let runUnfinished: - @Sendable ( - @Sendable (StoreTransactionDelivery) async -> Void - ) async -> Void package let runSubscriptionStatusUpdates: @Sendable ( @Sendable () async -> Void @@ -42,10 +38,6 @@ package struct StoreTransactionSource: Sendable { @escaping @Sendable ( @Sendable (StoreTransactionDelivery) async -> Void ) async -> Void, - runUnfinished: - @escaping @Sendable ( - @Sendable (StoreTransactionDelivery) async -> Void - ) async -> Void, runSubscriptionStatusUpdates: @escaping @Sendable ( @Sendable () async -> Void @@ -65,7 +57,6 @@ package struct StoreTransactionSource: Sendable { ) -> StoreTransactionDelivery ) { self.runUpdates = runUpdates - self.runUnfinished = runUnfinished self.runSubscriptionStatusUpdates = runSubscriptionStatusUpdates self.currentEntitlements = currentEntitlements self.queryUnfinished = queryUnfinished diff --git a/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift b/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift index 655cf54..2a6eea2 100644 --- a/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift +++ b/Tests/StoreTransactionKitTests/CompletedDeliveryRefreshTests.swift @@ -20,7 +20,7 @@ struct CompletedDeliveryRefreshTests { await handlerCalls.send() } let entitlements = EntitlementRefreshCoordinator( - query: { try await query.next() }, + query: { _ in try await query.next() }, didChange: { value in await publications.append(UInt64(value.transactions.count)) } diff --git a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift index 57ef83b..6d1f375 100644 --- a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift +++ b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift @@ -8,7 +8,7 @@ struct EntitlementRefreshCoordinatorTests { let query = ControlledEntitlementQuery() let publicationSizes = UInt64Recorder() let coordinator = EntitlementRefreshCoordinator( - query: { try await query.next() }, + query: { _ in try await query.next() }, didChange: { value in await publicationSizes.append(UInt64(value.transactions.count)) } @@ -38,7 +38,7 @@ struct EntitlementRefreshCoordinatorTests { let query = ControlledEntitlementQuery() let publicationSizes = UInt64Recorder() let coordinator = EntitlementRefreshCoordinator( - query: { try await query.next() }, + query: { _ in try await query.next() }, didChange: { value in await publicationSizes.append(UInt64(value.transactions.count)) } @@ -65,7 +65,7 @@ struct EntitlementRefreshCoordinatorTests { let query = ControlledEntitlementQuery() let publicationSizes = UInt64Recorder() let coordinator = EntitlementRefreshCoordinator( - query: { try await query.next() }, + query: { _ in try await query.next() }, didChange: { value in await publicationSizes.append(UInt64(value.transactions.count)) } @@ -92,7 +92,7 @@ struct EntitlementRefreshCoordinatorTests { func pendingBatchReportingAuthority() async throws { let query = ControlledEntitlementQuery() let coordinator = EntitlementRefreshCoordinator( - query: { try await query.next() }, + query: { _ in try await query.next() }, didChange: { _ in } ) @@ -113,4 +113,66 @@ struct EntitlementRefreshCoordinatorTests { _ = try await nextObserver.receipt.terminalValue() await coordinator.sealAndDrain() } + + @Test("mixed retry policies form contiguous query batches") + func mixedRetryPolicyBatches() async throws { + let query = ControlledEntitlementQuery() + let policies = StringRecorder() + let coordinator = EntitlementRefreshCoordinator( + query: { retryFailedTransactions in + await policies.append(String(retryFailedTransactions)) + return try await query.next() + }, + didChange: { _ in } + ) + + let active = await coordinator.reserve( + retryFailedTransactions: false + ) + try await query.waitForRequest(1) + let falseOwner = await coordinator.reserve( + retryFailedTransactions: false + ) + let falseObserver = await coordinator.reserve( + retryFailedTransactions: false + ) + let trueOwner = await coordinator.reserve( + retryFailedTransactions: true + ) + let trueObserver = await coordinator.reserve( + retryFailedTransactions: true + ) + let trailingFalseOwner = await coordinator.reserve( + retryFailedTransactions: false + ) + + #expect(falseOwner.role == .owner) + #expect(falseObserver.role == .observer) + #expect(trueOwner.role == .owner) + #expect(trueObserver.role == .observer) + #expect(trailingFalseOwner.role == .owner) + + await query.succeed([]) + _ = try await active.receipt.terminalValue() + + try await query.waitForRequest(2) + await query.succeed([]) + _ = try await falseOwner.receipt.terminalValue() + _ = try await falseObserver.receipt.terminalValue() + + try await query.waitForRequest(3) + await query.succeed([]) + _ = try await trueOwner.receipt.terminalValue() + _ = try await trueObserver.receipt.terminalValue() + + try await query.waitForRequest(4) + await query.succeed([]) + _ = try await trailingFalseOwner.receipt.terminalValue() + + #expect( + await policies.snapshot() == [ + "false", "false", "true", "false", + ]) + await coordinator.sealAndDrain() + } } diff --git a/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift b/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift index 344166c..e51edbb 100644 --- a/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift +++ b/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift @@ -100,7 +100,7 @@ struct RestoreCoordinatorFailureTests { let synchronization = ControlledRestoreSynchronization() let entitlementQueryCount = TestSignal() let entitlements = EntitlementRefreshCoordinator( - query: { + query: { _ in await entitlementQueryCount.send() return [] }, @@ -147,7 +147,6 @@ struct SessionClosingAdmissionTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in }, diff --git a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift index 96bf802..c910c89 100644 --- a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift +++ b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift @@ -67,7 +67,9 @@ struct ReconciliationFixedPointTests { failures: failures ) - let snapshots = try await reconciler.query() + let snapshots = try await reconciler.query( + retryFailedTransactions: false + ) await core.finishInputAndDrain() await failures.sealAndDrain() @@ -154,7 +156,9 @@ struct ReconciliationFixedPointTests { failures: failures ) - let snapshots = try await reconciler.query() + let snapshots = try await reconciler.query( + retryFailedTransactions: false + ) await core.finishInputAndDrain() await failures.sealAndDrain() @@ -216,7 +220,9 @@ struct ReconciliationFixedPointTests { ) do { - _ = try await reconciler.query() + _ = try await reconciler.query( + retryFailedTransactions: false + ) Issue.record("A failed unfinished transaction unexpectedly reconciled.") } catch let owned as StoreTransactionFailureWithReportingOwner { #expect(owned.underlyingError is TestFailure) @@ -227,7 +233,9 @@ struct ReconciliationFixedPointTests { #expect(await finishes.value() == 0) #expect(await reports.snapshot() == ["unfinished-44"]) - let snapshots = try await reconciler.query() + let snapshots = try await reconciler.query( + retryFailedTransactions: true + ) #expect(snapshots.isEmpty) #expect(await handlerCalls.value() == 2) @@ -295,7 +303,9 @@ struct ReconciliationFixedPointTests { failures: failures ) - let snapshots = try await reconciler.query() + let snapshots = try await reconciler.query( + retryFailedTransactions: false + ) #expect(snapshots == [snapshot]) #expect(await currentQueryCount.value() == 2) diff --git a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift index 532375a..32b2a83 100644 --- a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift @@ -44,7 +44,6 @@ struct RuntimeContractTests { @Test("immediate purchase outcomes return their semantic values") func immediatePurchaseOutcomes() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let handlerCalls = TestSignal() let reports = StringRecorder() let session = StoreTransactionSession( @@ -72,7 +71,6 @@ struct RuntimeContractTests { @Test("immediate purchase outcomes honor caller cancellation") func immediatePurchaseOutcomeCancellation() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in }, @@ -103,7 +101,7 @@ struct RuntimeContractTests { let synchronizationGate = TestGate() let entitlementQueryCount = TestSignal() let entitlements = EntitlementRefreshCoordinator( - query: { + query: { _ in await entitlementQueryCount.send() return [] }, @@ -147,7 +145,6 @@ struct RuntimeContractTests { throw TestFailure() } ) - fixture.unfinished.finish() let runtime = StoreTransactionRuntime( sessionID: UUID(), source: fixture.source, @@ -200,7 +197,6 @@ struct RuntimeContractTests { throw TestFailure() } ) - fixture.unfinished.finish() let runtime = StoreTransactionRuntime( sessionID: UUID(), source: fixture.source, @@ -250,7 +246,6 @@ struct RuntimeContractTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in }, @@ -289,7 +284,6 @@ struct RuntimeContractTests { let fixture = TestSourceFixture( queryUnfinished: { await unfinished.read() } ) - fixture.unfinished.finish() let handlerCalls = TestSignal() let finishes = TestSignal() let reports = StringRecorder() @@ -350,7 +344,6 @@ struct RuntimeContractTests { let fixture = TestSourceFixture( queryUnfinished: { await unfinished.read() } ) - fixture.unfinished.finish() let handled = UInt64Recorder() let consumableAttempts = TestSignal() let directFinishes = TestSignal() @@ -444,7 +437,6 @@ struct RuntimeContractTests { queryUnfinished: { await unfinished.read() }, synchronize: { await synchronizations.send() } ) - fixture.unfinished.finish() let handlerCalls = TestSignal() let finishes = TestSignal() let reports = StringRecorder() @@ -505,7 +497,6 @@ struct RuntimeContractTests { let fixture = TestSourceFixture( queryUnfinished: { await unfinished.read() } ) - fixture.unfinished.finish() let handlerStarted = TestSignal() let handlerGate = TestGate() let reported = TestSignal() @@ -563,7 +554,6 @@ struct RuntimeContractTests { queryUnfinished: { await unfinished.read() }, synchronize: { await synchronizations.send() } ) - fixture.unfinished.finish() let handlerStarted = TestSignal() let handlerGate = TestGate() let reported = TestSignal() @@ -639,7 +629,6 @@ struct RuntimeContractTests { [older, lowerID, higherIDRevoked, newestSigned] } ) - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in }, @@ -662,7 +651,6 @@ struct RuntimeContractTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in }, @@ -736,7 +724,7 @@ struct RuntimeContractTests { #expect(observer.role == .inFlightObserver) let entitlements = EntitlementRefreshCoordinator( - query: { + query: { _ in try await reconciler.drain([ CurrentEntitlementReconciler.AcceptedTransaction( snapshot: snapshot, @@ -815,7 +803,9 @@ struct RuntimeContractTests { failures: failures ) - let query = Task { try await reconciler.query() } + let query = Task { + try await reconciler.query(retryFailedTransactions: false) + } try await unfinishedQueryStarted.wait(for: 1) #expect(await entitlementQueryCount.value() == 1) @@ -845,7 +835,7 @@ struct RuntimeContractTests { throw TestFailure() } let entitlements = EntitlementRefreshCoordinator( - query: { + query: { _ in Issue.record("A failed transaction unexpectedly refreshed entitlements.") return [] }, @@ -920,7 +910,7 @@ struct RuntimeContractTests { throw TestFailure() } let entitlements = EntitlementRefreshCoordinator( - query: { + query: { _ in Issue.record("A failed transaction unexpectedly refreshed entitlements.") return [] }, @@ -939,6 +929,7 @@ struct RuntimeContractTests { ) await pipeline.processBackground(delivery, source: .updates) + await core.completeInitialAttempt() await pipeline.processBackground(delivery, source: .unfinished) #expect(await handlerCalls.value() == 2) @@ -956,7 +947,6 @@ struct RuntimeContractTests { let reported = TestSignal() let reports = StringRecorder() let fixture = TestSourceFixture() - fixture.unfinished.finish() let runtime = StoreTransactionRuntime( sessionID: UUID(), source: fixture.source, @@ -1007,7 +997,6 @@ struct RuntimeContractTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() let runtime = StoreTransactionRuntime( sessionID: UUID(), source: fixture.source, @@ -1074,7 +1063,6 @@ struct RuntimeContractTests { let closeCallersStarted = TestSignal() let closeCallersFinished = TestSignal() let fixture = TestSourceFixture() - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in diff --git a/Tests/StoreTransactionKitTests/StoreTests.swift b/Tests/StoreTransactionKitTests/StoreTests.swift index e44a9ec..4175ab9 100644 --- a/Tests/StoreTransactionKitTests/StoreTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTests.swift @@ -18,7 +18,6 @@ struct StoreTests { let fixture = TestSourceFixture( currentEntitlements: { await values.read() } ) - fixture.unfinished.finish() let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, @@ -62,7 +61,6 @@ struct StoreTests { ] } ) - fixture.unfinished.finish() let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, @@ -87,7 +85,6 @@ struct StoreTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, @@ -126,11 +123,16 @@ struct StoreTests { [.verified(makeEnvelope(snapshot: snapshot))] } ) - fixture.unfinished.finish() + let handlerCalls = TestSignal() let reports = StringRecorder() let store = TransactionStore( source: fixture.source, - handleTransaction: { _ in throw TestFailure() }, + handleTransaction: { _ in + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + }, reportFailure: { failure in if failure.source == .unfinished, failure.transactionID == snapshot.id, @@ -147,17 +149,27 @@ struct StoreTests { #expect(store.startupError is TestFailure) #expect(store.entitlements == nil) + #expect(await handlerCalls.value() == 1) + #expect(await reports.snapshot() == ["unfinished-8"]) + + let entitlements = try await store.refreshEntitlements() + + #expect(entitlements.transactions.isEmpty) + #expect(store.startupError == nil) + #expect(await handlerCalls.value() == 2) #expect(await reports.snapshot() == ["unfinished-8"]) try await store.close() } - @Test("a stale startup failure cannot replace newer entitlement readiness") - func newerReadinessWinsStartupFailureRace() async throws { + @Test("a background refresh failure preserves resolved entitlement state") + func backgroundFailurePreservesResolvedEntitlements() async throws { let snapshot = makeSnapshot( id: 7, productID: SubscriptionID.monthly.rawValue ) let query = ControlledEntitlementQuery() + let reports = StringRecorder() + let reported = TestSignal() let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) @@ -165,23 +177,33 @@ struct StoreTests { source: fixture.source, handleTransaction: { _ in }, reportFailure: { failure in - Issue.record("Unexpected background failure: \(failure)") + if failure.source == .entitlementRefresh, + failure.transactionID == snapshot.id, + failure.productID == snapshot.productID, + failure.underlyingError is TestFailure + { + await reports.append("entitlement-refresh-7") + } else { + await reports.append("unexpected") + } + await reported.send() } ) - fixture.updates.yield( - .verified(makeEnvelope(snapshot: snapshot)) - ) try await query.waitForRequest(1) await query.succeed([snapshot]) + await store.waitForStartup() - fixture.unfinished.finish() + fixture.updates.yield( + .verified(makeEnvelope(snapshot: snapshot)) + ) try await query.waitForRequest(2) await query.fail(TestFailure()) - await store.waitForStartup() + try await reported.wait(for: 1) #expect(store.activeEntitlements == [.monthly]) #expect(store.startupError == nil) + #expect(await reports.snapshot() == ["entitlement-refresh-7"]) try await store.close() } @@ -190,7 +212,6 @@ struct StoreTests { let fixture = TestSourceFixture( currentEntitlements: { throw CancellationError() } ) - fixture.unfinished.finish() let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, @@ -207,7 +228,6 @@ struct StoreTests { @Test("an empty set means entitlement resolution completed") func emptyTypedEntitlementProjection() async throws { let fixture = TestSourceFixture(currentEntitlements: { [] }) - fixture.unfinished.finish() let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, @@ -222,16 +242,19 @@ struct StoreTests { @Test("the TransactionStore facade rejects handler reentry during startup") func startupHandlerReentrancy() async throws { - let fixture = TestSourceFixture() let holder = TransactionStoreHolder() let rejected = TestSignal() let finished = TestSignal() - fixture.unfinished.yield( - .verified( - makeEnvelope(snapshot: makeSnapshot(id: 5)) { - await finished.send() - })) - fixture.unfinished.finish() + let fixture = TestSourceFixture( + queryUnfinished: { + [ + .verified( + makeEnvelope(snapshot: makeSnapshot(id: 5)) { + await finished.send() + }) + ] + } + ) let store = TransactionStore( source: fixture.source, @@ -264,7 +287,6 @@ struct StoreTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() let store = TransactionStore( source: fixture.source, handleTransaction: { _ in }, @@ -288,15 +310,14 @@ struct StoreTests { func reentrantClosePreservesStartup() async throws { let query = ControlledEntitlementQuery() let fixture = TestSourceFixture( - currentEntitlements: { try await query.next() } + currentEntitlements: { try await query.next() }, + queryUnfinished: { + [.verified(makeEnvelope(snapshot: makeSnapshot(id: 6)))] + } ) let holder = TransactionStoreHolder() let closeRejected = TestSignal() let startupCompleted = TestSignal() - fixture.unfinished.yield( - .verified(makeEnvelope(snapshot: makeSnapshot(id: 6))) - ) - fixture.unfinished.finish() let store = TransactionStore( source: fixture.source, @@ -320,12 +341,13 @@ struct StoreTests { await startupCompleted.send() } - try await closeRejected.wait(for: 1) try await query.waitForRequest(1) #expect(await startupCompleted.value() == 0) await query.succeed([]) + try await closeRejected.wait(for: 1) try await query.waitForRequest(2) + #expect(await startupCompleted.value() == 0) await query.succeed([]) await startupWaiter.value #expect(await startupCompleted.value() == 1) diff --git a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift index bec938d..08b39a6 100644 --- a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift @@ -7,7 +7,6 @@ struct StoreTransactionSessionTests { @Test("start publishes initial entitlements and close terminates producers") func startAndClose() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let publicationSizes = UInt64Recorder() let session = StoreTransactionSession( source: fixture.source, @@ -45,7 +44,6 @@ struct StoreTransactionSessionTests { ] } ) - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { transaction in @@ -74,13 +72,161 @@ struct StoreTransactionSessionTests { try await session.close() } + @Test("startup does not retry an update failure through unfinished reconciliation") + func startupSharesFailedUpdateWithUnfinishedReconciliation() async throws { + let snapshot = makeSnapshot( + id: 42, + productID: "consumable.startup", + productType: .consumable + ) + let envelope = makeEnvelope(snapshot: snapshot) + let handlerCalls = TestSignal() + let finishes = TestSignal() + let reported = TestSignal() + let reports = StringRecorder() + let fixture = TestSourceFixture( + currentEntitlements: { + try await reported.wait(for: 1) + return [] + }, + queryUnfinished: { + [ + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + }) + ] + } + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { _ in + await handlerCalls.send() + if await handlerCalls.value() == 1 { + throw TestFailure() + } + }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + if failure.source == .updates, + failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("updates-42") + } else { + await reports.append("unexpected") + } + await reported.send() + } + ) + + fixture.updates.yield(.verified(envelope)) + + await #expect(throws: TestFailure.self) { + _ = try await session.start() + } + #expect(await handlerCalls.value() == 1) + #expect(await finishes.value() == 0) + #expect(await reports.snapshot() == ["updates-42"]) + + _ = try await session.currentEntitlements() + + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 1) + #expect(await reports.snapshot() == ["updates-42"]) + try await session.close() + } + + @Test("cancelling the startup waiter does not open a retry boundary") + func cancelledStartupWaiterKeepsInitialAttempt() async throws { + let failedSnapshot = makeSnapshot( + id: 43, + productID: "consumable.cancelled-startup", + productType: .consumable + ) + let markerSnapshot = makeSnapshot(id: 44) + let query = ControlledEntitlementQuery() + let handled = UInt64Recorder() + let failedFinish = TestSignal() + let markerFinish = TestSignal() + let reported = TestSignal() + let reports = StringRecorder() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() }, + queryUnfinished: { + [ + .verified( + makeEnvelope(snapshot: failedSnapshot) { + await failedFinish.send() + }) + ] + } + ) + let session = StoreTransactionSession( + source: fixture.source, + handleTransaction: { snapshot in + await handled.append(snapshot.id) + if snapshot.id == failedSnapshot.id { + throw TestFailure() + } + }, + entitlementsDidChange: { _ in }, + reportFailure: { failure in + if failure.source == .updates, + failure.transactionID == failedSnapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("updates-43") + } else { + await reports.append("unexpected") + } + await reported.send() + } + ) + + let startup = Task { try await session.start() } + try await query.waitForRequest(1) + fixture.updates.yield( + .verified(makeEnvelope(snapshot: failedSnapshot)) + ) + try await reported.wait(for: 1) + + startup.cancel() + await #expect(throws: CancellationError.self) { + _ = try await startup.value + } + + fixture.updates.yield( + .verified(makeEnvelope(snapshot: failedSnapshot)) + ) + fixture.updates.yield( + .verified( + makeEnvelope(snapshot: markerSnapshot) { + await markerFinish.send() + }) + ) + try await markerFinish.wait(for: 1) + + #expect(await handled.snapshot() == [43, 44]) + #expect(await failedFinish.value() == 0) + #expect(await reports.snapshot() == ["updates-43"]) + + await query.succeed([]) + try await query.waitForRequest(2) + await query.succeed([]) + try await session.close() + + #expect(await handled.snapshot() == [43, 44]) + #expect(await failedFinish.value() == 0) + #expect(await reports.snapshot() == ["updates-43"]) + } + @Test("subscription status waits for initial entitlement readiness") func subscriptionStatusWaitsForReadiness() async throws { let query = ControlledEntitlementQuery() let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() let session = StoreTransactionSession( source: fixture.source, handleTransaction: { _ in }, @@ -110,7 +256,6 @@ struct StoreTransactionSessionTests { let fixture = TestSourceFixture( currentEntitlements: { try await query.next() } ) - fixture.unfinished.finish() let closeCompleted = TestSignal() let session = StoreTransactionSession( source: fixture.source, @@ -149,7 +294,6 @@ struct StoreTransactionSessionTests { let fixture = TestSourceFixture( currentEntitlements: { await values.read() } ) - fixture.unfinished.finish() let handlerCalls = TestSignal() let publications = UInt64Recorder() let published = TestSignal() @@ -188,7 +332,6 @@ struct StoreTransactionSessionTests { currentEntitlements: { await values.read() }, queryUnfinished: { await unfinished.read() } ) - fixture.unfinished.finish() let handlerStarted = TestSignal() let handlerGate = TestGate() let events = StringRecorder() @@ -252,7 +395,6 @@ struct StoreTransactionSessionTests { currentEntitlements: { await values.read() }, queryUnfinished: { await unfinished.read() } ) - fixture.unfinished.finish() let handlerCalls = TestSignal() let finishes = TestSignal() let publications = UInt64Recorder() @@ -337,7 +479,6 @@ struct StoreTransactionSessionTests { currentEntitlements: { await values.read() }, queryUnfinished: { await unfinished.read() } ) - fixture.unfinished.finish() let handlerStarted = TestSignal() let handlerGate = TestGate() let events = StringRecorder() @@ -407,7 +548,6 @@ struct StoreTransactionSessionTests { let fixture = TestSourceFixture( queryUnfinished: { await unfinished.read() } ) - fixture.unfinished.finish() let handlerCalls = TestSignal() let reports = UInt64Recorder() let reported = TestSignal() @@ -449,7 +589,6 @@ struct StoreTransactionSessionTests { [verificationFailure] } ) - fixture.unfinished.finish() let reported = TestSignal() let session = StoreTransactionSession( source: fixture.source, @@ -472,7 +611,6 @@ struct StoreTransactionSessionTests { @Test("updates use the durable handler then finish and refresh") func updateProcessing() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let events = StringRecorder() let finished = TestSignal() let session = StoreTransactionSession( @@ -504,7 +642,6 @@ struct StoreTransactionSessionTests { @Test("background handler failures are reported and never finished") func backgroundFailure() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let reported = TestSignal() let finished = TestSignal() let session = StoreTransactionSession( @@ -550,7 +687,6 @@ struct StoreTransactionSessionTests { @Test("callbacks reject reentry into their own session") func callbackReentrancy() async throws { let fixture = TestSourceFixture() - fixture.unfinished.finish() let holder = SessionHolder() let observations = StringRecorder() let finished = TestSignal() @@ -625,7 +761,6 @@ struct StoreTransactionSessionTests { reportFailure: { _ in } ) let fixture = TestSourceFixture() - fixture.unfinished.finish() let callbackCompleted = TestSignal() let session = StoreTransactionSession( source: fixture.source, diff --git a/Tests/StoreTransactionKitTests/TestSupport.swift b/Tests/StoreTransactionKitTests/TestSupport.swift index 7ecc30f..db83e82 100644 --- a/Tests/StoreTransactionKitTests/TestSupport.swift +++ b/Tests/StoreTransactionKitTests/TestSupport.swift @@ -292,7 +292,6 @@ struct TestFailure: Error, Sendable, Equatable {} struct TestSourceFixture: Sendable { let source: StoreTransactionSource let updates: AsyncStream.Continuation - let unfinished: AsyncStream.Continuation let subscriptionStatusUpdates: AsyncStream.Continuation let updateTermination: TestSignal let subscriptionStatusDeliveryCount: TestSignal @@ -315,7 +314,6 @@ struct TestSourceFixture: Sendable { synchronize: @escaping @Sendable () async throws -> Void = {} ) { let updatePair = AsyncStream.makeStream() - let unfinishedPair = AsyncStream.makeStream() let subscriptionStatusPair = AsyncStream.makeStream() let updateTermination = TestSignal() let subscriptionStatusDeliveryCount = TestSignal() @@ -328,7 +326,6 @@ struct TestSourceFixture: Sendable { Task { await subscriptionStatusTermination.send() } } self.updates = updatePair.continuation - self.unfinished = unfinishedPair.continuation self.subscriptionStatusUpdates = subscriptionStatusPair.continuation self.updateTermination = updateTermination self.subscriptionStatusDeliveryCount = subscriptionStatusDeliveryCount @@ -340,11 +337,6 @@ struct TestSourceFixture: Sendable { await consume(delivery) } }, - runUnfinished: { consume in - for await delivery in unfinishedPair.stream { - await consume(delivery) - } - }, runSubscriptionStatusUpdates: { consume in for await _ in subscriptionStatusPair.stream { await subscriptionStatusDeliveryCount.send() diff --git a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift index 2285333..f29b081 100644 --- a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift +++ b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift @@ -54,6 +54,8 @@ struct TransactionProcessingCoreTests { } #expect(await finishes.value() == 0) + await core.completeInitialAttempt() + #expect(await core.beginTransactionAttempt()) let second = await core.accept(envelope) _ = try await second.receipt.terminalValue() #expect(first.role == .owner) From a0cc0c2ad5d1cdf678d98d44188fb30810475df9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:35:57 +0900 Subject: [PATCH 11/17] fix(storekit): preserve verification diagnostics Report unverified unfinished and current-entitlement diagnostics when durable reconciliation terminates with a handler failure, without repeating diagnostics from nonterminal fixed-point iterations. --- .../CurrentEntitlementReconciler.swift | 28 +++++- .../ReconciliationFixedPointTests.swift | 92 +++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift index 5431c5a..1b9213c 100644 --- a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift +++ b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift @@ -69,18 +69,36 @@ package final class CurrentEntitlementReconciler: Sendable { } guard !acceptedTransactions.isEmpty else { - await reportUnfinishedVerificationFailures( - unfinishedVerificationFailures + await reportVerificationFailures( + unfinished: unfinishedVerificationFailures, + currentEntitlements: result.verificationFailures ) - await reportVerificationFailures(result.verificationFailures) return result.snapshots } - try await drain(acceptedTransactions) + do { + try await drain(acceptedTransactions) + } catch { + await reportVerificationFailures( + unfinished: unfinishedVerificationFailures, + currentEntitlements: result.verificationFailures + ) + throw error + } reconciledRevisions.formUnion(iterationRevisions) } } + private func reportVerificationFailures( + unfinished: [any Error], + currentEntitlements: [StoreTransactionVerificationError] + ) async { + await reportUnfinishedVerificationFailures(unfinished) + await reportCurrentEntitlementVerificationFailures( + currentEntitlements + ) + } + private func reportUnfinishedVerificationFailures( _ verificationFailures: [any Error] ) async { @@ -123,7 +141,7 @@ package final class CurrentEntitlementReconciler: Sendable { } } - private func reportVerificationFailures( + private func reportCurrentEntitlementVerificationFailures( _ verificationFailures: [StoreTransactionVerificationError] ) async { for failure in verificationFailures { diff --git a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift index c910c89..c24b608 100644 --- a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift +++ b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift @@ -316,6 +316,94 @@ struct ReconciliationFixedPointTests { await core.finishInputAndDrain() await failures.sealAndDrain() } + + @Test("a terminal handler failure preserves verification diagnostics") + func handlerFailurePreservesVerificationDiagnostics() async throws { + let snapshot = makeSnapshot( + id: 46, + productID: "consumable.diagnostics", + productType: .consumable + ) + let finishes = TestSignal() + let reports = StringRecorder() + let currentFailure = StoreTransactionVerificationError( + underlyingError: TerminalCurrentVerificationFailure() + ) + let core = TransactionProcessingCore { _ in + throw TestFailure() + } + let failures = FailureReporterDispatcher { failure in + switch failure.source { + case .unfinished: + if failure.transactionID == snapshot.id, + failure.underlyingError is TestFailure + { + await reports.append("handler") + } else if failure.transactionID == nil, + failure.underlyingError + is TerminalUnfinishedVerificationFailure + { + await reports.append("unfinished-verification") + } else { + await reports.append("unexpected") + } + case .currentEntitlementVerification: + guard + let verificationFailure = + failure.underlyingError + as? StoreTransactionVerificationError, + verificationFailure.underlyingError + is TerminalCurrentVerificationFailure + else { + await reports.append("unexpected") + return + } + await reports.append("current-verification") + default: + await reports.append("unexpected") + } + } + let reconciler = CurrentEntitlementReconciler( + query: { + CurrentEntitlementQueryResult( + snapshots: [], + verificationFailures: [currentFailure] + ) + }, + queryUnfinished: { + [ + .unverified(TerminalUnfinishedVerificationFailure()), + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + }), + ] + }, + core: core, + failures: failures + ) + + do { + _ = try await reconciler.query( + retryFailedTransactions: false + ) + Issue.record("A failed handler unexpectedly reconciled.") + } catch let owned as StoreTransactionFailureWithReportingOwner { + #expect(owned.underlyingError is TestFailure) + } catch { + Issue.record("Unexpected reconciliation error: \(error)") + } + + #expect(await finishes.value() == 0) + #expect( + await reports.snapshot() == [ + "handler", + "unfinished-verification", + "current-verification", + ]) + await core.finishInputAndDrain() + await failures.sealAndDrain() + } } private struct DiscardedVerificationFailure: Error, Sendable {} @@ -323,3 +411,7 @@ private struct DiscardedVerificationFailure: Error, Sendable {} private struct StableVerificationFailure: Error, Sendable {} private struct PersistentUnfinishedVerificationFailure: Error, Sendable {} + +private struct TerminalUnfinishedVerificationFailure: Error, Sendable {} + +private struct TerminalCurrentVerificationFailure: Error, Sendable {} From 74415b4ca811b0b42c4d73a6463cfac63f5c227c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:27:26 +0900 Subject: [PATCH 12/17] fix(storekit): preserve startup failure ordering --- .../EntitlementRefreshCoordinator.swift | 26 +++++++- .../Runtime/StoreTransactionRuntime.swift | 15 ++++- .../StoreEntitlements.swift | 12 +++- .../StoreTransactionSession.swift | 21 +++++++ .../TransactionStore.swift | 63 +++++++++++++++---- .../EntitlementRefreshCoordinatorTests.swift | 5 ++ .../RuntimeContractTests.swift | 47 ++++++++++++++ .../StoreTransactionKitTests/StoreTests.swift | 35 +++++++++++ 8 files changed, 206 insertions(+), 18 deletions(-) diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift index 0d53f1a..7067f95 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift @@ -8,6 +8,12 @@ package struct EntitlementRefreshReservation: Sendable { package let receipt: ProcessingReceipt package let role: Role + package let token: UInt64 +} + +package struct EntitlementRefreshSuccess: Sendable { + package let token: UInt64 + package let entitlements: StoreEntitlements } package actor EntitlementRefreshCoordinator { @@ -20,6 +26,7 @@ package actor EntitlementRefreshCoordinator { private let sessionID: UUID private let query: @Sendable (Bool) async throws -> [StoreTransactionSnapshot] private let didChange: @Sendable (StoreEntitlements) async -> Void + private let didSucceed: @Sendable (EntitlementRefreshSuccess) async -> Void private var nextToken: UInt64 = 0 private var current: StoreEntitlements? private var pending: [PendingReservation] = [] @@ -31,11 +38,14 @@ package actor EntitlementRefreshCoordinator { query: @escaping @Sendable (Bool) async throws -> [StoreTransactionSnapshot], - didChange: @escaping @Sendable (StoreEntitlements) async -> Void + didChange: @escaping @Sendable (StoreEntitlements) async -> Void, + didSucceed: + @escaping @Sendable (EntitlementRefreshSuccess) async -> Void = { _ in } ) { self.sessionID = sessionID self.query = query self.didChange = didChange + self.didSucceed = didSucceed } package func reserve( @@ -46,7 +56,8 @@ package actor EntitlementRefreshCoordinator { receipt: .failed( StoreTransactionInternalError.entitlementRefreshClosed ), - role: .owner + role: .owner, + token: 0 ) } precondition(nextToken < .max) @@ -63,7 +74,11 @@ package actor EntitlementRefreshCoordinator { ) ) startWorkerIfNeeded() - return EntitlementRefreshReservation(receipt: receipt, role: role) + return EntitlementRefreshReservation( + receipt: receipt, + role: role, + token: nextToken + ) } package func sealAndDrain() async { @@ -109,6 +124,11 @@ package actor EntitlementRefreshCoordinator { await didChange(published) } } + await didSucceed( + EntitlementRefreshSuccess( + token: reservations.last!.token, + entitlements: published + )) for reservation in reservations { reservation.receipt.succeed(published) } diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index 8535419..5924b15 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -37,6 +37,8 @@ package final class StoreTransactionRuntime: Sendable { @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, entitlementsDidChange: @escaping @Sendable (StoreEntitlements) async -> Void, + entitlementRefreshDidSucceed: + @escaping @Sendable (EntitlementRefreshSuccess) async -> Void = { _ in }, reportFailure: @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void ) { @@ -63,7 +65,8 @@ package final class StoreTransactionRuntime: Sendable { retryFailedTransactions: retryFailedTransactions ) }, - didChange: entitlementsDidChange + didChange: entitlementsDidChange, + didSucceed: entitlementRefreshDidSucceed ) let pipeline = StoreTransactionPipeline( core: core, @@ -122,7 +125,8 @@ package final class StoreTransactionRuntime: Sendable { do { result = .success( StoreTransactionReadiness( - entitlements: try await reservation.receipt.terminalValue() + entitlements: try await reservation.receipt.terminalValue(), + refreshToken: reservation.token )) } catch let owned as StoreTransactionFailureWithReportingOwner { result = .failure(owned.underlyingError) @@ -136,7 +140,12 @@ package final class StoreTransactionRuntime: Sendable { case .success(let readiness): completion.succeed(readiness) case .failure(let error): - completion.fail(error) + completion.fail( + StoreTransactionReadinessFailure( + refreshToken: reservation.token, + underlyingError: error + ) + ) } } finiteTasks.insert(task) diff --git a/Sources/StoreTransactionKit/StoreEntitlements.swift b/Sources/StoreTransactionKit/StoreEntitlements.swift index 2c0b7f1..7ac4f64 100644 --- a/Sources/StoreTransactionKit/StoreEntitlements.swift +++ b/Sources/StoreTransactionKit/StoreEntitlements.swift @@ -15,8 +15,18 @@ public struct StoreEntitlements: Sendable, Equatable { /// The initial entitlement publication completed by `start()`. package struct StoreTransactionReadiness: Sendable, Equatable { let entitlements: StoreEntitlements + let refreshToken: UInt64 - package init(entitlements: StoreEntitlements) { + package init( + entitlements: StoreEntitlements, + refreshToken: UInt64 + ) { self.entitlements = entitlements + self.refreshToken = refreshToken } } + +package struct StoreTransactionReadinessFailure: Error { + let refreshToken: UInt64 + let underlyingError: any Error +} diff --git a/Sources/StoreTransactionKit/StoreTransactionSession.swift b/Sources/StoreTransactionKit/StoreTransactionSession.swift index f5d5bad..e79df6e 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSession.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSession.swift @@ -15,6 +15,7 @@ package actor StoreTransactionSession { let source: StoreTransactionSource let handleTransaction: @Sendable (StoreTransactionSnapshot) async throws -> Void let entitlementsDidChange: @Sendable (StoreEntitlements) async -> Void + let entitlementRefreshDidSucceed: @Sendable (EntitlementRefreshSuccess) async -> Void let reportFailure: @Sendable (StoreTransactionBackgroundFailure) async -> Void } @@ -57,6 +58,7 @@ package actor StoreTransactionSession { source: .live, handleTransaction: handleTransaction, entitlementsDidChange: entitlementsDidChange, + entitlementRefreshDidSucceed: { _ in }, reportFailure: reportFailure )) } @@ -68,6 +70,8 @@ package actor StoreTransactionSession { @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, entitlementsDidChange: @escaping @Sendable (StoreEntitlements) async -> Void = { _ in }, + entitlementRefreshDidSucceed: + @escaping @Sendable (EntitlementRefreshSuccess) async -> Void = { _ in }, reportFailure: @escaping @Sendable (StoreTransactionBackgroundFailure) async -> Void ) { @@ -77,6 +81,7 @@ package actor StoreTransactionSession { source: source, handleTransaction: handleTransaction, entitlementsDidChange: entitlementsDidChange, + entitlementRefreshDidSucceed: entitlementRefreshDidSucceed, reportFailure: reportFailure )) } @@ -95,6 +100,20 @@ package actor StoreTransactionSession { /// - Throws: A lifecycle or callback reentrancy error, an entitlement query /// error, or `CancellationError` when the attached waiter cancels. package func start() async throws -> StoreTransactionReadiness { + do { + return try await startPreservingReadinessFailure() + } catch let failure as StoreTransactionReadinessFailure { + throw failure.underlyingError + } + } + + package func startForTransactionStore() async throws -> StoreTransactionReadiness { + try await startPreservingReadinessFailure() + } + + private func startPreservingReadinessFailure() async throws + -> StoreTransactionReadiness + { guard case .initialized(let configuration) = state else { switch state { case .running: throw StoreTransactionLifecycleError.alreadyStarted @@ -109,6 +128,8 @@ package actor StoreTransactionSession { source: configuration.source, handleTransaction: configuration.handleTransaction, entitlementsDidChange: configuration.entitlementsDidChange, + entitlementRefreshDidSucceed: + configuration.entitlementRefreshDidSucceed, reportFailure: configuration.reportFailure ) state = .running(runtime) diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index dec6c8f..a7c9bb2 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -58,6 +58,7 @@ where @ObservationIgnored private let transactionSession: StoreTransactionSession @ObservationIgnored private let startupCompletion: ProcessingReceipt @ObservationIgnored private var startupTask: Task? + @ObservationIgnored private var startupOrdering = TransactionStoreStartupOrdering() /// Creates and starts an observable StoreKit store. /// @@ -99,8 +100,9 @@ where sessionID: sessionID, source: source, handleTransaction: handleTransaction, - entitlementsDidChange: { entitlements in - await owner.apply(entitlements) + entitlementsDidChange: { _ in }, + entitlementRefreshDidSucceed: { success in + await owner.apply(success) }, reportFailure: reportFailure ) @@ -112,13 +114,18 @@ where startupTask = Task { [weak self, transactionSession] in defer { startupCompletion.succeed(()) } do { - _ = try await transactionSession.start() + _ = try await transactionSession.startForTransactionStore() + } catch let failure as StoreTransactionReadinessFailure { + guard !Task.isCancelled else { return } + self?.applyStartupFailure( + token: failure.refreshToken, + error: failure.underlyingError + ) } catch { // Explicit close cancels only this readiness waiter. The // session's close operation owns draining accepted work. - guard !Task.isCancelled, self?.entitlements == nil else { - return - } + guard !Task.isCancelled else { return } + self?.startupOrdering.recordUnsequencedFailure() self?.startupError = error } } @@ -193,9 +200,20 @@ where startupTask = nil } - fileprivate func apply(_ entitlements: StoreEntitlements) { - self.entitlements = entitlements - startupError = nil + fileprivate func apply(_ success: EntitlementRefreshSuccess) { + entitlements = success.entitlements + if startupOrdering.recordSuccess(token: success.token) { + startupError = nil + } + } + + private func applyStartupFailure( + token: UInt64, + error: any Error + ) { + if startupOrdering.recordFailure(token: token) { + startupError = error + } } private func waitForStartupAttempt( @@ -229,6 +247,29 @@ where } } +package struct TransactionStoreStartupOrdering: Sendable { + private var latestSuccessfulToken: UInt64 = 0 + private var failureToken: UInt64? + + package mutating func recordSuccess(token: UInt64) -> Bool { + precondition(token > latestSuccessfulToken) + latestSuccessfulToken = token + guard let failureToken, token > failureToken else { return false } + self.failureToken = nil + return true + } + + package mutating func recordFailure(token: UInt64) -> Bool { + guard latestSuccessfulToken < token else { return false } + failureToken = token + return true + } + + package mutating func recordUnsequencedFailure() { + failureToken = latestSuccessfulToken + } +} + @MainActor private final class TransactionStoreOwner: Sendable where @@ -237,7 +278,7 @@ where { weak var store: TransactionStore? - func apply(_ entitlements: StoreEntitlements) { - store?.apply(entitlements) + func apply(_ success: EntitlementRefreshSuccess) { + store?.apply(success) } } diff --git a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift index 6d1f375..cba9ca3 100644 --- a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift +++ b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift @@ -37,10 +37,14 @@ struct EntitlementRefreshCoordinatorTests { func equalContentDoesNotPublish() async throws { let query = ControlledEntitlementQuery() let publicationSizes = UInt64Recorder() + let successfulTokens = UInt64Recorder() let coordinator = EntitlementRefreshCoordinator( query: { _ in try await query.next() }, didChange: { value in await publicationSizes.append(UInt64(value.transactions.count)) + }, + didSucceed: { success in + await successfulTokens.append(success.token) } ) let snapshot = makeSnapshot(id: 4) @@ -57,6 +61,7 @@ struct EntitlementRefreshCoordinatorTests { #expect(value.transactions == [snapshot]) #expect(await publicationSizes.snapshot() == [1]) + #expect(await successfulTokens.snapshot() == [1, 2]) await coordinator.sealAndDrain() } diff --git a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift index 32b2a83..1909531 100644 --- a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift @@ -5,6 +5,53 @@ import Testing @Suite("Runtime contracts", .timeLimit(.minutes(1))) struct RuntimeContractTests { + @Test("refresh success and readiness failure preserve their physical order") + func readinessFailureOrdering() async throws { + let query = ControlledEntitlementQuery() + let successfulTokens = UInt64Recorder() + let fixture = TestSourceFixture( + currentEntitlements: { try await query.next() } + ) + let runtime = StoreTransactionRuntime( + sessionID: UUID(), + source: fixture.source, + handleTransaction: { _ in }, + entitlementsDidChange: { _ in }, + entitlementRefreshDidSucceed: { success in + await successfulTokens.append(success.token) + }, + reportFailure: { _ in } + ) + fixture.updates.yield( + .verified( + makeEnvelope( + snapshot: makeSnapshot( + id: 91, + productType: .nonConsumable + ) + ) + ) + ) + + try await query.waitForRequest(1) + let readiness = Task { try await runtime.readiness() } + await query.succeed([makeSnapshot(id: 91, productType: .nonConsumable)]) + try await query.waitForRequest(2) + await query.fail(TestFailure()) + + do { + _ = try await readiness.value + Issue.record("Readiness unexpectedly succeeded.") + } catch let failure as StoreTransactionReadinessFailure { + #expect(failure.refreshToken == 2) + #expect(failure.underlyingError is TestFailure) + } catch { + Issue.record("Unexpected readiness failure: \(error)") + } + #expect(await successfulTokens.snapshot() == [1]) + await runtime.close() + } + @Test("receipt waiter cancellation is distinct from terminal cancellation failure") func receiptCancellationIdentity() async throws { let terminalFailure = ProcessingReceipt() diff --git a/Tests/StoreTransactionKitTests/StoreTests.swift b/Tests/StoreTransactionKitTests/StoreTests.swift index 4175ab9..16d0cbd 100644 --- a/Tests/StoreTransactionKitTests/StoreTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTests.swift @@ -9,6 +9,41 @@ struct StoreTests { case yearly = "subscription.yearly" } + @Test("a later startup failure is preserved after an earlier success") + func startupFailureAfterSuccess() { + var ordering = TransactionStoreStartupOrdering() + + let clearedBySuccess = ordering.recordSuccess(token: 1) + let recordedFailure = ordering.recordFailure(token: 2) + + #expect(!clearedBySuccess) + #expect(recordedFailure) + } + + @Test("a later success clears an earlier startup failure") + func successAfterStartupFailure() { + var ordering = TransactionStoreStartupOrdering() + + let recordedFailure = ordering.recordFailure(token: 1) + let clearedBySuccess = ordering.recordSuccess(token: 2) + + #expect(recordedFailure) + #expect(clearedBySuccess) + } + + @Test("only a success later than the failed readiness boundary recovers startup") + func sequencedStartupRecovery() { + var ordering = TransactionStoreStartupOrdering() + + let clearedBeforeFailure = ordering.recordSuccess(token: 1) + let recordedFailure = ordering.recordFailure(token: 2) + let clearedAfterFailure = ordering.recordSuccess(token: 3) + + #expect(!clearedBeforeFailure) + #expect(recordedFailure) + #expect(clearedAfterFailure) + } + @Test("app-defined identifiers project current entitlements") func typedEntitlementProjection() async throws { let values = EntitlementValueSource([ From d4a64f9e8745bd3fe7dfb37004a8554e14cef654 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:34:04 +0900 Subject: [PATCH 13/17] fix(storekit): track attached failure observers --- .../EntitlementRefreshCoordinator.swift | 25 ++- .../TransactionProcessingCore.swift | 53 ++++-- .../Runtime/DirectOperationReporting.swift | 162 +++++++++++++++++ .../Runtime/RestoreCoordinator.swift | 30 ++- .../Runtime/StoreTransactionRuntime.swift | 172 +++++++++++------- .../DirectOperationReportingTests.swift | 73 ++++++++ .../EntitlementRefreshCoordinatorTests.swift | 2 + .../LifecycleResidualTests.swift | 2 + .../TransactionProcessingCoreTests.swift | 2 + 9 files changed, 430 insertions(+), 91 deletions(-) create mode 100644 Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift create mode 100644 Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift index 7067f95..1fac461 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift @@ -9,6 +9,7 @@ package struct EntitlementRefreshReservation: Sendable { package let receipt: ProcessingReceipt package let role: Role package let token: UInt64 + package let reportingAuthority: DirectOperationReportingAuthority } package struct EntitlementRefreshSuccess: Sendable { @@ -21,6 +22,7 @@ package actor EntitlementRefreshCoordinator { let token: UInt64 let retryFailedTransactions: Bool let receipt: ProcessingReceipt + let reportingAuthority: DirectOperationReportingAuthority } private let sessionID: UUID @@ -57,27 +59,38 @@ package actor EntitlementRefreshCoordinator { StoreTransactionInternalError.entitlementRefreshClosed ), role: .owner, - token: 0 + token: 0, + reportingAuthority: DirectOperationReportingAuthority() ) } precondition(nextToken < .max) nextToken += 1 - let role: EntitlementRefreshReservation.Role = - pending.last?.retryFailedTransactions == retryFailedTransactions - ? .observer : .owner + let role: EntitlementRefreshReservation.Role + let reportingAuthority: DirectOperationReportingAuthority + if let preceding = pending.last, + preceding.retryFailedTransactions == retryFailedTransactions + { + role = .observer + reportingAuthority = preceding.reportingAuthority + } else { + role = .owner + reportingAuthority = DirectOperationReportingAuthority() + } let receipt = ProcessingReceipt() pending.append( PendingReservation( token: nextToken, retryFailedTransactions: retryFailedTransactions, - receipt: receipt + receipt: receipt, + reportingAuthority: reportingAuthority ) ) startWorkerIfNeeded() return EntitlementRefreshReservation( receipt: receipt, role: role, - token: nextToken + token: nextToken, + reportingAuthority: reportingAuthority ) } diff --git a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift index f14b798..00b9b35 100644 --- a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift +++ b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift @@ -10,19 +10,25 @@ package struct ProcessingAcceptance: Sendable { package let receipt: ProcessingReceipt package let role: Role + package let reportingAuthority: DirectOperationReportingAuthority } package actor TransactionProcessingCore { + private struct Attempt: Sendable { + let receipt: ProcessingReceipt + let reportingAuthority: DirectOperationReportingAuthority + } + private struct QueuedOperation: Sendable { let envelope: ProcessingEnvelope - let receipt: ProcessingReceipt + let attempt: Attempt } private let sessionID: UUID private let handle: @Sendable (Value) async throws -> Void private var queue: [QueuedOperation] = [] - private var inFlight: [Data: ProcessingReceipt] = [:] - private var failed: [Data: ProcessingReceipt] = [:] + private var inFlight: [Data: Attempt] = [:] + private var failed: [Data: Attempt] = [:] private var completed = CompletedRevisionCache() private var worker: Task? private var acceptsInput = true @@ -42,33 +48,44 @@ package actor TransactionProcessingCore { guard acceptsInput else { return ProcessingAcceptance( receipt: .failed(StoreTransactionInternalError.inputClosed), - role: .owner + role: .owner, + reportingAuthority: DirectOperationReportingAuthority() ) } if completed.contains(envelope.revision) { return ProcessingAcceptance( receipt: .succeeded(envelope.value), - role: .completedObserver + role: .completedObserver, + reportingAuthority: DirectOperationReportingAuthority() ) } - if let receipt = inFlight[envelope.revision] { + if let attempt = inFlight[envelope.revision] { return ProcessingAcceptance( - receipt: receipt, - role: .inFlightObserver + receipt: attempt.receipt, + role: .inFlightObserver, + reportingAuthority: attempt.reportingAuthority ) } - if let receipt = failed[envelope.revision] { + if let attempt = failed[envelope.revision] { return ProcessingAcceptance( - receipt: receipt, - role: .failedObserver + receipt: attempt.receipt, + role: .failedObserver, + reportingAuthority: attempt.reportingAuthority ) } - let receipt = ProcessingReceipt() - inFlight[envelope.revision] = receipt - queue.append(QueuedOperation(envelope: envelope, receipt: receipt)) + let attempt = Attempt( + receipt: ProcessingReceipt(), + reportingAuthority: DirectOperationReportingAuthority() + ) + inFlight[envelope.revision] = attempt + queue.append(QueuedOperation(envelope: envelope, attempt: attempt)) startWorkerIfNeeded() - return ProcessingAcceptance(receipt: receipt, role: .owner) + return ProcessingAcceptance( + receipt: attempt.receipt, + role: .owner, + reportingAuthority: attempt.reportingAuthority + ) } package func retryFailedTransactionsInNewAttempt() -> Bool { @@ -123,11 +140,11 @@ package actor TransactionProcessingCore { await operation.envelope.finish() completed.insert(operation.envelope.revision) inFlight.removeValue(forKey: operation.envelope.revision) - operation.receipt.succeed(operation.envelope.value) + operation.attempt.receipt.succeed(operation.envelope.value) } catch { inFlight.removeValue(forKey: operation.envelope.revision) - failed[operation.envelope.revision] = operation.receipt - operation.receipt.fail(error) + failed[operation.envelope.revision] = operation.attempt + operation.attempt.receipt.fail(error) } } worker = nil diff --git a/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift b/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift new file mode 100644 index 0000000..7914b75 --- /dev/null +++ b/Sources/StoreTransactionKit/Runtime/DirectOperationReporting.swift @@ -0,0 +1,162 @@ +import Foundation +import Synchronization + +package final class DirectOperationReportingAuthority: Sendable { + private enum ParticipantState: Equatable { + case attached + case abandoned + } + + private struct State { + var participants: [UUID: ParticipantState] = [:] + var delivered = false + var report: StoreTransactionBackgroundFailure? + var claimed = false + } + + private let state = Mutex(State()) + + package init() {} + + fileprivate func attach(abandoned: Bool) -> UUID { + state.withLock { state in + let id = UUID() + state.participants[id] = abandoned ? .abandoned : .attached + return id + } + } + + fileprivate func succeed(participant id: UUID) { + state.withLock { state in + _ = state.participants.removeValue(forKey: id) + } + } + + fileprivate func fail( + participant id: UUID, + report: StoreTransactionBackgroundFailure? + ) -> StoreTransactionBackgroundFailure? { + state.withLock { state in + guard state.participants[id] != nil else { return nil } + if state.report == nil { + state.report = report + } + return claimReportIfAbandoned(state: &state) + } + } + + fileprivate func abandon( + participant id: UUID + ) -> StoreTransactionBackgroundFailure? { + state.withLock { state in + guard state.participants[id] != nil else { return nil } + state.participants[id] = .abandoned + return claimReportIfAbandoned(state: &state) + } + } + + fileprivate func deliver(participant id: UUID) { + state.withLock { state in + guard state.participants.removeValue(forKey: id) != nil else { + return + } + state.delivered = true + } + } + + private func claimReportIfAbandoned( + state: inout State + ) -> StoreTransactionBackgroundFailure? { + guard !state.claimed, !state.delivered, let report = state.report else { + return nil + } + guard state.participants.values.allSatisfy({ $0 == .abandoned }) else { + return nil + } + state.claimed = true + return report + } +} + +package final class DirectOperationObservation: Sendable { + package struct Binding: Sendable { + fileprivate let participantID: UUID + fileprivate let authority: DirectOperationReportingAuthority + } + + private enum Disposition: Equatable { + case attached + case abandoned + case delivered + } + + private struct State { + var disposition: Disposition = .attached + var binding: Binding? + } + + private let state = Mutex(State()) + + package init() {} + + package func bind( + to authority: DirectOperationReportingAuthority + ) -> Binding { + state.withLock { state in + precondition(state.binding == nil) + precondition(state.disposition != .delivered) + let binding = Binding( + participantID: authority.attach( + abandoned: state.disposition == .abandoned + ), + authority: authority + ) + state.binding = binding + return binding + } + } + + package func succeed(_ binding: Binding) { + let active = state.withLock { state -> Binding in + precondition(state.binding?.participantID == binding.participantID) + state.binding = nil + return binding + } + active.authority.succeed(participant: active.participantID) + } + + package func fail( + _ binding: Binding, + report: StoreTransactionBackgroundFailure? + ) -> StoreTransactionBackgroundFailure? { + let active = state.withLock { state -> Binding in + precondition(state.binding?.participantID == binding.participantID) + return binding + } + return active.authority.fail( + participant: active.participantID, + report: report + ) + } + + package func abandon() -> StoreTransactionBackgroundFailure? { + let active = state.withLock { state -> Binding? in + guard state.disposition != .delivered else { return nil } + state.disposition = .abandoned + return state.binding + } + guard let active else { return nil } + return active.authority.abandon(participant: active.participantID) + } + + package func deliver() { + let active = state.withLock { state -> Binding? in + guard state.disposition != .delivered else { return nil } + state.disposition = .delivered + defer { state.binding = nil } + return state.binding + } + guard let active else { return } + active.authority.deliver(participant: active.participantID) + } +} diff --git a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift index 0191694..90b9dc9 100644 --- a/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift +++ b/Sources/StoreTransactionKit/Runtime/RestoreCoordinator.swift @@ -6,20 +6,24 @@ package struct RestoreReservation: Sendable { package let receipt: ProcessingReceipt package let role: Role + package let reportingAuthority: DirectOperationReportingAuthority } package struct RestoreCoordinatorFailure: Error { package let underlyingError: any Error package let reportsWhenAbandoned: Bool + package let reportingAuthority: DirectOperationReportingAuthority package init( propagating error: any Error, - reportsWhenAbandoned: Bool + reportsWhenAbandoned: Bool, + reportingAuthority: DirectOperationReportingAuthority ) { let propagation = StoreTransactionFailurePropagation(error) self.underlyingError = propagation.underlyingError self.reportsWhenAbandoned = reportsWhenAbandoned && !propagation.hasReportingOwner + self.reportingAuthority = reportingAuthority } } @@ -27,6 +31,7 @@ package actor RestoreCoordinator { private struct InFlight: Sendable { let id: UInt64 let receipt: ProcessingReceipt + let reportingAuthority: DirectOperationReportingAuthority let task: Task } @@ -49,7 +54,8 @@ package actor RestoreCoordinator { if let inFlight { return RestoreReservation( receipt: inFlight.receipt, - role: .observer + role: .observer, + reportingAuthority: inFlight.reportingAuthority ) } @@ -57,6 +63,7 @@ package actor RestoreCoordinator { nextID += 1 let id = nextID let receipt = ProcessingReceipt() + let reportingAuthority = DirectOperationReportingAuthority() let synchronize = synchronize let entitlements = entitlements let task = Task.detached { [weak self] in @@ -69,7 +76,8 @@ package actor RestoreCoordinator { result: .failure( RestoreCoordinatorFailure( propagating: error, - reportsWhenAbandoned: true + reportsWhenAbandoned: true, + reportingAuthority: reportingAuthority )) ) return @@ -84,13 +92,23 @@ package actor RestoreCoordinator { result = .failure( RestoreCoordinatorFailure( propagating: error, - reportsWhenAbandoned: refresh.role == .owner + reportsWhenAbandoned: refresh.role == .owner, + reportingAuthority: refresh.reportingAuthority )) } await self?.complete(id: id, result: result) } - inFlight = InFlight(id: id, receipt: receipt, task: task) - return RestoreReservation(receipt: receipt, role: .owner) + inFlight = InFlight( + id: id, + receipt: receipt, + reportingAuthority: reportingAuthority, + task: task + ) + return RestoreReservation( + receipt: receipt, + role: .owner, + reportingAuthority: reportingAuthority + ) } private func complete( diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index 5924b15..d7438c8 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -2,17 +2,6 @@ import StoreKit private struct DirectOperationFailure: Error { let underlyingError: any Error - let reportsWhenAbandoned: Bool - - init( - propagating error: any Error, - reportsWhenAbandoned: Bool - ) { - let propagation = StoreTransactionFailurePropagation(error) - self.underlyingError = propagation.underlyingError - self.reportsWhenAbandoned = - reportsWhenAbandoned && !propagation.hasReportingOwner - } } package final class StoreTransactionRuntime: Sendable { @@ -213,6 +202,10 @@ package final class StoreTransactionRuntime: Sendable { leases.observer.end() throw error } + let observation = DirectOperationObservation() + let transactionBinding = observation.bind( + to: accepted.acceptance.reportingAuthority + ) let operationReceipt = ProcessingReceipt() let entitlements = entitlements let task = Task { @@ -223,27 +216,40 @@ package final class StoreTransactionRuntime: Sendable { .terminalValue() } catch { operationReceipt.fail( - DirectOperationFailure( + await directFailure( + observation: observation, + binding: transactionBinding, propagating: error, reportsWhenAbandoned: - accepted.acceptance.role == .owner + accepted.acceptance.role == .owner, + operation: .processPurchase, + snapshot: accepted.snapshot ) ) return } + observation.succeed(transactionBinding) let refresh = await entitlements.reserve( retryFailedTransactions: accepted.retryFailedTransactions ) + let refreshBinding = observation.bind( + to: refresh.reportingAuthority + ) do { _ = try await refresh.receipt.terminalValue() + observation.succeed(refreshBinding) operationReceipt.succeed(snapshot) } catch { operationReceipt.fail( - DirectOperationFailure( + await directFailure( + observation: observation, + binding: refreshBinding, propagating: error, - reportsWhenAbandoned: refresh.role == .owner + reportsWhenAbandoned: refresh.role == .owner, + operation: .processPurchase, + snapshot: accepted.snapshot ) ) } @@ -251,8 +257,7 @@ package final class StoreTransactionRuntime: Sendable { finiteTasks.insert(task) return try await outcome( receipt: operationReceipt, - operation: .processPurchase, - snapshot: accepted.snapshot, + observation: observation, observerLease: leases.observer ) { .completed($0) } } @@ -262,22 +267,29 @@ package final class StoreTransactionRuntime: Sendable { ) async throws -> StoreEntitlements { let retryFailedTransactions = await core.retryFailedTransactionsInNewAttempt() + let refresh = await entitlements.reserve( + retryFailedTransactions: retryFailedTransactions + ) + let observation = DirectOperationObservation() + let binding = observation.bind(to: refresh.reportingAuthority) let operationReceipt = ProcessingReceipt() - let entitlements = entitlements let task = Task { defer { leases.work.end() } - let refresh = await entitlements.reserve( - retryFailedTransactions: retryFailedTransactions - ) do { + let value = try await refresh.receipt.terminalValue() + observation.succeed(binding) operationReceipt.succeed( - try await refresh.receipt.terminalValue() + value ) } catch { operationReceipt.fail( - DirectOperationFailure( + await directFailure( + observation: observation, + binding: binding, propagating: error, - reportsWhenAbandoned: refresh.role == .owner + reportsWhenAbandoned: refresh.role == .owner, + operation: .currentEntitlements, + snapshot: nil ) ) } @@ -285,8 +297,7 @@ package final class StoreTransactionRuntime: Sendable { finiteTasks.insert(task) return try await outcome( receipt: operationReceipt, - operation: .currentEntitlements, - snapshot: nil, + observation: observation, observerLease: leases.observer ) { $0 } } @@ -295,6 +306,10 @@ package final class StoreTransactionRuntime: Sendable { for productID: Product.ID, leases: FiniteOperationLeases ) async throws -> [StoreTransactionSnapshot] { + let observation = DirectOperationObservation() + let binding = observation.bind( + to: DirectOperationReportingAuthority() + ) let operationReceipt = ProcessingReceipt<[StoreTransactionSnapshot]>() let source = source let task = Task { @@ -302,16 +317,25 @@ package final class StoreTransactionRuntime: Sendable { do { let snapshots = try await source.history(productID) .sorted(by: Self.historyOrder) + observation.succeed(binding) operationReceipt.succeed(snapshots) } catch { - operationReceipt.fail(error) + operationReceipt.fail( + await directFailure( + observation: observation, + binding: binding, + propagating: error, + reportsWhenAbandoned: true, + operation: .history, + snapshot: nil + ) + ) } } finiteTasks.insert(task) return try await outcome( receipt: operationReceipt, - operation: .history, - snapshot: nil, + observation: observation, observerLease: leases.observer ) { $0 } } @@ -324,20 +348,39 @@ package final class StoreTransactionRuntime: Sendable { let restore = await restoreCoordinator.reserve( retryFailedTransactions: retryFailedTransactions ) + let observation = DirectOperationObservation() + let restoreBinding = observation.bind( + to: restore.reportingAuthority + ) let operationReceipt = ProcessingReceipt() let task = Task { defer { leases.work.end() } do { + let value = try await restore.receipt.terminalValue() + observation.succeed(restoreBinding) operationReceipt.succeed( - try await restore.receipt.terminalValue() + value ) } catch let failure as RestoreCoordinatorFailure { + let failureBinding: DirectOperationObservation.Binding + if failure.reportingAuthority === restore.reportingAuthority { + failureBinding = restoreBinding + } else { + observation.succeed(restoreBinding) + failureBinding = observation.bind( + to: failure.reportingAuthority + ) + } operationReceipt.fail( - DirectOperationFailure( + await directFailure( + observation: observation, + binding: failureBinding, propagating: failure.underlyingError, reportsWhenAbandoned: restore.role == .owner - && failure.reportsWhenAbandoned + && failure.reportsWhenAbandoned, + operation: .restorePurchases, + snapshot: nil ) ) } catch { @@ -349,8 +392,7 @@ package final class StoreTransactionRuntime: Sendable { finiteTasks.insert(task) return try await outcome( receipt: operationReceipt, - operation: .restorePurchases, - snapshot: nil, + observation: observation, observerLease: leases.observer ) { $0 } } @@ -374,52 +416,60 @@ package final class StoreTransactionRuntime: Sendable { private func outcome( receipt: ProcessingReceipt, - operation: StoreTransactionOperation, - snapshot: StoreTransactionSnapshot?, + observation: DirectOperationObservation, observerLease: FiniteOperationLease, transform: (Value) -> Output ) async throws -> Output { do { let value = try await receipt.value() + observation.deliver() observerLease.end() return transform(value) } catch is ProcessingReceiptWaiterCancellation { - let failures = failures - let task = Task { - defer { observerLease.end() } - do { - _ = try await receipt.terminalValue() - } catch let failure as DirectOperationFailure { - guard failure.reportsWhenAbandoned else { return } - await failures.enqueue( - StoreTransactionBackgroundFailure( - source: .abandonedDirectOperation(operation), - transactionID: snapshot?.id, - productID: snapshot?.productID, - underlyingError: failure.underlyingError - ) - ) - } catch { - await failures.enqueue( - StoreTransactionBackgroundFailure( - source: .abandonedDirectOperation(operation), - transactionID: snapshot?.id, - productID: snapshot?.productID, - underlyingError: error - )) - } + if let report = observation.abandon() { + await failures.enqueue(report) } - finiteTasks.insert(task) + observerLease.end() throw CancellationError() } catch let failure as DirectOperationFailure { + observation.deliver() observerLease.end() throw failure.underlyingError } catch { + observation.deliver() observerLease.end() throw error } } + private func directFailure( + observation: DirectOperationObservation, + binding: DirectOperationObservation.Binding, + propagating error: any Error, + reportsWhenAbandoned: Bool, + operation: StoreTransactionOperation, + snapshot: StoreTransactionSnapshot? + ) async -> DirectOperationFailure { + let propagation = StoreTransactionFailurePropagation(error) + let report: StoreTransactionBackgroundFailure? + if reportsWhenAbandoned && !propagation.hasReportingOwner { + report = StoreTransactionBackgroundFailure( + source: .abandonedDirectOperation(operation), + transactionID: snapshot?.id, + productID: snapshot?.productID, + underlyingError: propagation.underlyingError + ) + } else { + report = nil + } + if let claimed = observation.fail(binding, report: report) { + await failures.enqueue(claimed) + } + return DirectOperationFailure( + underlyingError: propagation.underlyingError + ) + } + package static func historyOrder( _ lhs: StoreTransactionSnapshot, _ rhs: StoreTransactionSnapshot diff --git a/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift b/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift new file mode 100644 index 0000000..7baf55c --- /dev/null +++ b/Tests/StoreTransactionKitTests/DirectOperationReportingTests.swift @@ -0,0 +1,73 @@ +import Testing +@testable import StoreTransactionKit + +@Suite("Direct operation reporting authority") +struct DirectOperationReportingTests { + @Test("an attached caller receives the failure without a background report") + func attachedCallerOwnsFailureDelivery() throws { + let authority = DirectOperationReportingAuthority() + let owner = DirectOperationObservation() + let observer = DirectOperationObservation() + let ownerBinding = owner.bind(to: authority) + let observerBinding = observer.bind(to: authority) + + #expect(owner.abandon() == nil) + #expect( + owner.fail(ownerBinding, report: makeReport(id: 1)) == nil + ) + #expect(observer.fail(observerBinding, report: nil) == nil) + observer.deliver() + + #expect(owner.abandon() == nil) + } + + @Test("the last abandoned caller claims the physical owner's report once") + func lastAbandonedCallerClaimsOwnerReport() throws { + let authority = DirectOperationReportingAuthority() + let owner = DirectOperationObservation() + let observer = DirectOperationObservation() + let ownerBinding = owner.bind(to: authority) + let observerBinding = observer.bind(to: authority) + + #expect( + owner.fail(ownerBinding, report: makeReport(id: 2)) == nil + ) + #expect(observer.fail(observerBinding, report: nil) == nil) + #expect(owner.abandon() == nil) + let claimed = observer.abandon() + + #expect( + claimed?.source + == .abandonedDirectOperation(.currentEntitlements) + ) + #expect(claimed?.transactionID == 2) + #expect(claimed?.underlyingError is TestFailure) + #expect(observer.abandon() == nil) + } + + @Test("a failure claims once when every caller abandoned before completion") + func failureAfterEveryCallerAbandons() throws { + let authority = DirectOperationReportingAuthority() + let owner = DirectOperationObservation() + let observer = DirectOperationObservation() + let ownerBinding = owner.bind(to: authority) + let observerBinding = observer.bind(to: authority) + + #expect(owner.abandon() == nil) + #expect(observer.abandon() == nil) + #expect(observer.fail(observerBinding, report: nil) == nil) + let claimed = owner.fail(ownerBinding, report: makeReport(id: 3)) + + #expect(claimed?.transactionID == 3) + #expect(owner.fail(ownerBinding, report: makeReport(id: 3)) == nil) + } + + private func makeReport(id: UInt64) -> StoreTransactionBackgroundFailure { + StoreTransactionBackgroundFailure( + source: .abandonedDirectOperation(.currentEntitlements), + transactionID: id, + productID: "product-\(id)", + underlyingError: TestFailure() + ) + } +} diff --git a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift index cba9ca3..93d9c99 100644 --- a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift +++ b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift @@ -109,6 +109,8 @@ struct EntitlementRefreshCoordinatorTests { #expect(active.role == .owner) #expect(nextOwner.role == .owner) #expect(nextObserver.role == .observer) + #expect(active.reportingAuthority !== nextOwner.reportingAuthority) + #expect(nextOwner.reportingAuthority === nextObserver.reportingAuthority) await query.succeed([]) _ = try await active.receipt.terminalValue() diff --git a/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift b/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift index e51edbb..58769e2 100644 --- a/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift +++ b/Tests/StoreTransactionKitTests/LifecycleResidualTests.swift @@ -117,6 +117,7 @@ struct RestoreCoordinatorFailureTests { #expect(first.role == .owner) #expect(second.role == .observer) #expect(first.receipt === second.receipt) + #expect(first.reportingAuthority === second.reportingAuthority) await synchronization.releaseFirstAttempt() await #expect(throws: RestoreCoordinatorFailure.self) { @@ -129,6 +130,7 @@ struct RestoreCoordinatorFailureTests { let retry = await coordinator.reserve() #expect(retry.role == .owner) #expect(retry.receipt !== first.receipt) + #expect(retry.reportingAuthority !== first.reportingAuthority) try await synchronization.waitForAttempt(2) let value = try await retry.receipt.terminalValue() diff --git a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift index f29b081..e0cbeda 100644 --- a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift +++ b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift @@ -109,6 +109,8 @@ struct TransactionProcessingCoreTests { #expect(first.role == .owner) #expect(duplicate.role == .inFlightObserver) #expect(completed.role == .completedObserver) + #expect(first.reportingAuthority === duplicate.reportingAuthority) + #expect(first.reportingAuthority !== completed.reportingAuthority) #expect(await handles.value() == 1) #expect(await firstFinish.value() == 1) #expect(await duplicateFinish.value() == 0) From da92f3d24ba35c3933434c7847e44dbab0b5efe4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:28:18 +0900 Subject: [PATCH 14/17] fix(storekit): drain unfinished work before entitlement queries --- .../CurrentEntitlementReconciler.swift | 105 ++++++++++----- .../ReconciliationFixedPointTests.swift | 125 +++++++++++++++--- .../RuntimeContractTests.swift | 8 +- .../StoreTransactionKitTests/StoreTests.swift | 5 +- .../StoreTransactionSessionTests.swift | 40 +++--- 5 files changed, 204 insertions(+), 79 deletions(-) diff --git a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift index 1b9213c..f6a8676 100644 --- a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift +++ b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift @@ -15,6 +15,12 @@ package final class CurrentEntitlementReconciler: Sendable { } } + private struct UnfinishedBatch: Sendable { + let revisions: Set + let acceptedTransactions: [AcceptedTransaction] + let verificationFailures: [any Error] + } + private let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult private let queryUnfinished: @Sendable () async -> [StoreTransactionDelivery] private let core: TransactionProcessingCore @@ -42,51 +48,86 @@ package final class CurrentEntitlementReconciler: Sendable { await core.beginRetryAttempt() } var reconciledRevisions: Set = [] + var batch = await unfinishedBatch( + excluding: reconciledRevisions + ) + var precedingCurrentVerificationFailures: [StoreTransactionVerificationError] = [] while true { - let result = try await currentEntitlements() - var iterationRevisions: Set = [] - var acceptedTransactions: [AcceptedTransaction] = [] - var unfinishedVerificationFailures: [any Error] = [] - - for delivery in await queryUnfinished() { - switch delivery { - case .verified(let envelope): - guard - !reconciledRevisions.contains(envelope.revision), - iterationRevisions.insert(envelope.revision).inserted - else { - continue - } - acceptedTransactions.append( - AcceptedTransaction( - snapshot: envelope.value, - acceptance: await core.accept(envelope) - )) - case .unverified(let error): - unfinishedVerificationFailures.append(error) + while !batch.acceptedTransactions.isEmpty { + do { + try await drain(batch.acceptedTransactions) + } catch { + await reportVerificationFailures( + unfinished: batch.verificationFailures, + currentEntitlements: + precedingCurrentVerificationFailures + ) + throw error } - } - - guard !acceptedTransactions.isEmpty else { - await reportVerificationFailures( - unfinished: unfinishedVerificationFailures, - currentEntitlements: result.verificationFailures + reconciledRevisions.formUnion(batch.revisions) + batch = await unfinishedBatch( + excluding: reconciledRevisions ) - return result.snapshots } + let result: CurrentEntitlementQueryResult do { - try await drain(acceptedTransactions) + result = try await currentEntitlements() } catch { + await reportUnfinishedVerificationFailures( + batch.verificationFailures + ) + throw error + } + + let postQueryBatch = await unfinishedBatch( + excluding: reconciledRevisions + ) + guard !postQueryBatch.acceptedTransactions.isEmpty else { await reportVerificationFailures( - unfinished: unfinishedVerificationFailures, + unfinished: postQueryBatch.verificationFailures, currentEntitlements: result.verificationFailures ) - throw error + return result.snapshots + } + batch = postQueryBatch + precedingCurrentVerificationFailures = + result.verificationFailures + } + } + + private func unfinishedBatch( + excluding reconciledRevisions: Set + ) async -> UnfinishedBatch { + var revisions: Set = [] + var acceptedTransactions: [AcceptedTransaction] = [] + var verificationFailures: [any Error] = [] + + for delivery in await queryUnfinished() { + switch delivery { + case .verified(let envelope): + guard + !reconciledRevisions.contains(envelope.revision), + revisions.insert(envelope.revision).inserted + else { + continue + } + acceptedTransactions.append( + AcceptedTransaction( + snapshot: envelope.value, + acceptance: await core.accept(envelope) + )) + case .unverified(let error): + verificationFailures.append(error) } - reconciledRevisions.formUnion(iterationRevisions) } + + return UnfinishedBatch( + revisions: revisions, + acceptedTransactions: acceptedTransactions, + verificationFailures: verificationFailures + ) } private func reportVerificationFailures( diff --git a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift index c24b608..257a547 100644 --- a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift +++ b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift @@ -76,18 +76,24 @@ struct ReconciliationFixedPointTests { #expect(snapshots == [first, second]) #expect(await handled.snapshot() == [first.id, second.id]) #expect(await finished.snapshot() == [first.id, second.id]) - #expect(await currentQueryCount.value() == 3) - #expect(await unfinishedQueryCount.value() == 3) + #expect(await currentQueryCount.value() == 1) + #expect(await unfinishedQueryCount.value() == 4) #expect(await reports.snapshot().isEmpty) } @Test("only the stable query reports current entitlement verification failures") func reportsStableVerificationFailuresOnce() async throws { - let snapshot = makeSnapshot( + let first = makeSnapshot( id: 43, - productID: "lifetime.verified", + productID: "lifetime.first", productType: .nonConsumable, - jws: "fixed-point-verification" + jws: "fixed-point-verification-first" + ) + let second = makeSnapshot( + id: 47, + productID: "lifetime.second", + productType: .nonConsumable, + jws: "fixed-point-verification-second" ) let current = EntitlementValueSource([]) let unfinished = UnfinishedValueSource() @@ -97,17 +103,29 @@ struct ReconciliationFixedPointTests { let finishes = TestSignal() let reports = StringRecorder() - let persistentDelivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot) + let persistentFirst = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: first) ) - let initialDelivery = StoreTransactionDelivery.verified( - makeEnvelope(snapshot: snapshot) { - await current.replace(with: [snapshot]) - await unfinished.replace(with: [persistentDelivery]) + let persistentSecond = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: second) + ) + let secondDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: second) { + await current.replace(with: [first, second]) + await unfinished.replace( + with: [persistentFirst, persistentSecond] + ) await finishes.send() } ) - await unfinished.replace(with: [initialDelivery]) + let firstDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: first) { + await current.replace(with: [first]) + await unfinished.replace(with: [persistentFirst]) + await finishes.send() + } + ) + await unfinished.replace(with: [firstDelivery]) let core = TransactionProcessingCore { _ in await handlerCalls.send() @@ -133,6 +151,11 @@ struct ReconciliationFixedPointTests { query: { await currentQueryCount.send() let queryNumber = await currentQueryCount.value() + if queryNumber == 1 { + await unfinished.replace( + with: [persistentFirst, secondDelivery] + ) + } let underlyingError: any Error = if queryNumber == 1 { DiscardedVerificationFailure() @@ -162,11 +185,11 @@ struct ReconciliationFixedPointTests { await core.finishInputAndDrain() await failures.sealAndDrain() - #expect(snapshots == [snapshot]) + #expect(snapshots == [first, second]) #expect(await currentQueryCount.value() == 2) - #expect(await unfinishedQueryCount.value() == 2) - #expect(await handlerCalls.value() == 1) - #expect(await finishes.value() == 1) + #expect(await unfinishedQueryCount.value() == 5) + #expect(await handlerCalls.value() == 2) + #expect(await finishes.value() == 2) #expect(await reports.snapshot() == ["stable"]) } @@ -245,6 +268,63 @@ struct ReconciliationFixedPointTests { await failures.sealAndDrain() } + @Test("unfinished work is durable before a current entitlement query failure") + func handlesUnfinishedBeforeCurrentQueryFailure() async throws { + let snapshot = makeSnapshot( + id: 48, + productID: "consumable.before-query-failure", + productType: .consumable, + jws: "unfinished-before-query-failure" + ) + let unfinished = UnfinishedValueSource() + let events = StringRecorder() + let reports = StringRecorder() + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await events.append("finish") + await unfinished.replace(with: []) + } + ) + await unfinished.replace(with: [delivery]) + + let core = TransactionProcessingCore { _ in + await events.append("handle") + } + let failures = FailureReporterDispatcher { failure in + await reports.append("\(failure.source)") + } + let reconciler = CurrentEntitlementReconciler( + query: { + await events.append("current-entitlements") + throw CurrentEntitlementQueryFailure() + }, + queryUnfinished: { + await events.append("unfinished") + return await unfinished.read() + }, + core: core, + failures: failures + ) + + await #expect(throws: CurrentEntitlementQueryFailure.self) { + _ = try await reconciler.query( + retryFailedTransactions: false + ) + } + + #expect( + await events.snapshot() == [ + "unfinished", + "handle", + "finish", + "unfinished", + "current-entitlements", + ]) + #expect(await reports.snapshot().isEmpty) + await core.finishInputAndDrain() + await failures.sealAndDrain() + } + @Test("a persistent unverified unfinished delivery is reported by the stable query once") func persistentUnverifiedUnfinishedReportsOnce() async throws { let snapshot = makeSnapshot( @@ -308,8 +388,8 @@ struct ReconciliationFixedPointTests { ) #expect(snapshots == [snapshot]) - #expect(await currentQueryCount.value() == 2) - #expect(await unfinishedQueryCount.value() == 2) + #expect(await currentQueryCount.value() == 1) + #expect(await unfinishedQueryCount.value() == 3) #expect(await handlerCalls.value() == 1) #expect(await finishes.value() == 1) #expect(await reports.snapshot() == ["unverified"]) @@ -326,6 +406,7 @@ struct ReconciliationFixedPointTests { ) let finishes = TestSignal() let reports = StringRecorder() + let unfinishedQueryCount = TestSignal() let currentFailure = StoreTransactionVerificationError( underlyingError: TerminalCurrentVerificationFailure() ) @@ -371,7 +452,11 @@ struct ReconciliationFixedPointTests { ) }, queryUnfinished: { - [ + await unfinishedQueryCount.send() + guard await unfinishedQueryCount.value() > 1 else { + return [] + } + return [ .unverified(TerminalUnfinishedVerificationFailure()), .verified( makeEnvelope(snapshot: snapshot) { @@ -415,3 +500,5 @@ private struct PersistentUnfinishedVerificationFailure: Error, Sendable {} private struct TerminalUnfinishedVerificationFailure: Error, Sendable {} private struct TerminalCurrentVerificationFailure: Error, Sendable {} + +private struct CurrentEntitlementQueryFailure: Error, Sendable {} diff --git a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift index 1909531..fc96e7e 100644 --- a/Tests/StoreTransactionKitTests/RuntimeContractTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeContractTests.swift @@ -808,8 +808,8 @@ struct RuntimeContractTests { await failures.sealAndDrain() } - @Test("reconciliation requeries after handling a newly unfinished revision") - func reconciliationRequeriesAfterUnfinishedHandling() async throws { + @Test("reconciliation handles a new unfinished revision before querying entitlements") + func reconciliationHandlesUnfinishedBeforeQuerying() async throws { let entitlementQueryCount = TestSignal() let unfinishedQueryStarted = TestSignal() let unfinishedQueryGate = TestGate() @@ -854,13 +854,13 @@ struct RuntimeContractTests { try await reconciler.query(retryFailedTransactions: false) } try await unfinishedQueryStarted.wait(for: 1) - #expect(await entitlementQueryCount.value() == 1) + #expect(await entitlementQueryCount.value() == 0) await unfinishedQueryGate.open() let snapshots = try await query.value #expect(snapshots == [snapshot]) - #expect(await entitlementQueryCount.value() == 2) + #expect(await entitlementQueryCount.value() == 1) #expect(await handlerCalls.value() == 1) #expect(await finishes.value() == 1) await core.finishInputAndDrain() diff --git a/Tests/StoreTransactionKitTests/StoreTests.swift b/Tests/StoreTransactionKitTests/StoreTests.swift index 16d0cbd..6c17d2a 100644 --- a/Tests/StoreTransactionKitTests/StoreTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTests.swift @@ -378,11 +378,8 @@ struct StoreTests { try await query.waitForRequest(1) #expect(await startupCompleted.value() == 0) - - await query.succeed([]) try await closeRejected.wait(for: 1) - try await query.waitForRequest(2) - #expect(await startupCompleted.value() == 0) + await query.succeed([]) await startupWaiter.value #expect(await startupCompleted.value() == 1) diff --git a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift index 08b39a6..a3b0e99 100644 --- a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift @@ -84,19 +84,19 @@ struct StoreTransactionSessionTests { let finishes = TestSignal() let reported = TestSignal() let reports = StringRecorder() + let unfinished = UnfinishedValueSource() + let unfinishedDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + await unfinished.replace(with: []) + } + ) let fixture = TestSourceFixture( currentEntitlements: { try await reported.wait(for: 1) return [] }, - queryUnfinished: { - [ - .verified( - makeEnvelope(snapshot: snapshot) { - await finishes.send() - }) - ] - } + queryUnfinished: { await unfinished.read() } ) let session = StoreTransactionSession( source: fixture.source, @@ -116,6 +116,7 @@ struct StoreTransactionSessionTests { } else { await reports.append("unexpected") } + await unfinished.replace(with: [unfinishedDelivery]) await reported.send() } ) @@ -151,16 +152,16 @@ struct StoreTransactionSessionTests { let markerFinish = TestSignal() let reported = TestSignal() let reports = StringRecorder() + let unfinished = UnfinishedValueSource() + let failedDelivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: failedSnapshot) { + await failedFinish.send() + await unfinished.replace(with: []) + } + ) let fixture = TestSourceFixture( currentEntitlements: { try await query.next() }, - queryUnfinished: { - [ - .verified( - makeEnvelope(snapshot: failedSnapshot) { - await failedFinish.send() - }) - ] - } + queryUnfinished: { await unfinished.read() } ) let session = StoreTransactionSession( source: fixture.source, @@ -211,8 +212,7 @@ struct StoreTransactionSessionTests { #expect(await failedFinish.value() == 0) #expect(await reports.snapshot() == ["updates-43"]) - await query.succeed([]) - try await query.waitForRequest(2) + await unfinished.replace(with: [failedDelivery]) await query.succeed([]) try await session.close() @@ -367,12 +367,12 @@ struct StoreTransactionSessionTests { try await handlerStarted.wait(for: 1) #expect(await publications.snapshot() == [0]) - #expect(await fixture.entitlementQueryCount.value() == 2) + #expect(await fixture.entitlementQueryCount.value() == 1) await handlerGate.open() try await published.wait(for: 2) #expect(await events.snapshot() == ["handle-2", "finish-2"]) - #expect(await fixture.entitlementQueryCount.value() == 3) + #expect(await fixture.entitlementQueryCount.value() == 2) #expect(await publications.snapshot() == [0, 1]) try await session.close() } From 2ec9a2c41905a3cd00eca727cfdb97d1b529b4fc Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:28:27 +0900 Subject: [PATCH 15/17] ci(storekit): use compatible StoreKit runtime --- .github/workflows/ci.yml | 4 ++-- Tools/TestApp/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 249eb1b..73daa79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,8 +126,8 @@ jobs: run: | simulator_id="$(xcrun simctl list devices available --json | ruby -rjson -e ' runtimes = JSON.parse(STDIN.read).fetch("devices") - _, devices = runtimes.find { |name, _| name.end_with?(".iOS-26-4") } - abort("The iOS 26.4 Simulator runtime is required for StoreKit tests") unless devices + _, devices = runtimes.find { |name, _| name.end_with?(".iOS-26-2") } + abort("The iOS 26.2 Simulator runtime is required for StoreKit tests") unless devices device = devices.find { |candidate| candidate["isAvailable"] && candidate["name"].start_with?("iPhone") } abort("No available iPhone with iOS 26.4 found") unless device puts device.fetch("udid") diff --git a/Tools/TestApp/README.md b/Tools/TestApp/README.md index 0800718..5bd06be 100644 --- a/Tools/TestApp/README.md +++ b/Tools/TestApp/README.md @@ -37,7 +37,7 @@ signals. They don't use fixed sleeps or accelerated wall-clock time. A suite time limit exists only to surface a missing event as a failed test instead of hanging the test process. -CI runs this suite with Xcode 26.5 on the iOS 26.4 simulator available in the +CI runs this suite with Xcode 26.5 on the iOS 26.2 simulator available in the GitHub macOS 26 image. The same suite is also validated locally with Xcode 26.5 on iOS 18.6 to cover the supported iOS 18 line. From c4f63ef08881b9e0c50ba978db93e206e05d50ce Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:29:02 +0900 Subject: [PATCH 16/17] ci(storekit): correct runtime selection message --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73daa79..cd2dc5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,7 +129,7 @@ jobs: _, devices = runtimes.find { |name, _| name.end_with?(".iOS-26-2") } abort("The iOS 26.2 Simulator runtime is required for StoreKit tests") unless devices device = devices.find { |candidate| candidate["isAvailable"] && candidate["name"].start_with?("iPhone") } - abort("No available iPhone with iOS 26.4 found") unless device + abort("No available iPhone with iOS 26.2 found") unless device puts device.fetch("udid") ')" echo "SIMULATOR_ID=${simulator_id}" >> "$GITHUB_ENV" From dd39749e5bd33d3b8faa1fa3f26921e79ddc9345 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:52:03 +0900 Subject: [PATCH 17/17] fix(storekit): preserve reconciliation diagnostics --- .../CurrentEntitlementReconciler.swift | 55 +++++++++++++-- .../Runtime/StoreTransactionPipeline.swift | 2 +- .../LiveTransactionAdapter.swift | 3 +- .../StoreTransactionSource.swift | 3 +- .../ReconciliationFixedPointTests.swift | 68 ++++++++++++++++++- .../StoreTransactionSessionTests.swift | 6 +- 6 files changed, 124 insertions(+), 13 deletions(-) diff --git a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift index f6a8676..0501211 100644 --- a/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift +++ b/Sources/StoreTransactionKit/Entitlements/CurrentEntitlementReconciler.swift @@ -18,7 +18,12 @@ package final class CurrentEntitlementReconciler: Sendable { private struct UnfinishedBatch: Sendable { let revisions: Set let acceptedTransactions: [AcceptedTransaction] - let verificationFailures: [any Error] + let verificationFailures: [UnfinishedVerificationFailure] + } + + private struct UnfinishedVerificationFailure: Sendable { + let revision: Data + let error: any Error } private let currentEntitlements: @Sendable () async throws -> CurrentEntitlementQueryResult @@ -51,6 +56,13 @@ package final class CurrentEntitlementReconciler: Sendable { var batch = await unfinishedBatch( excluding: reconciledRevisions ) + var observedUnfinishedVerificationRevisions: Set = [] + var observedUnfinishedVerificationFailures: [any Error] = [] + collectUnfinishedVerificationFailures( + batch.verificationFailures, + observedRevisions: &observedUnfinishedVerificationRevisions, + observedFailures: &observedUnfinishedVerificationFailures + ) var precedingCurrentVerificationFailures: [StoreTransactionVerificationError] = [] while true { @@ -59,7 +71,7 @@ package final class CurrentEntitlementReconciler: Sendable { try await drain(batch.acceptedTransactions) } catch { await reportVerificationFailures( - unfinished: batch.verificationFailures, + unfinished: observedUnfinishedVerificationFailures, currentEntitlements: precedingCurrentVerificationFailures ) @@ -69,6 +81,13 @@ package final class CurrentEntitlementReconciler: Sendable { batch = await unfinishedBatch( excluding: reconciledRevisions ) + collectUnfinishedVerificationFailures( + batch.verificationFailures, + observedRevisions: + &observedUnfinishedVerificationRevisions, + observedFailures: + &observedUnfinishedVerificationFailures + ) } let result: CurrentEntitlementQueryResult @@ -76,7 +95,7 @@ package final class CurrentEntitlementReconciler: Sendable { result = try await currentEntitlements() } catch { await reportUnfinishedVerificationFailures( - batch.verificationFailures + observedUnfinishedVerificationFailures ) throw error } @@ -84,9 +103,16 @@ package final class CurrentEntitlementReconciler: Sendable { let postQueryBatch = await unfinishedBatch( excluding: reconciledRevisions ) + collectUnfinishedVerificationFailures( + postQueryBatch.verificationFailures, + observedRevisions: + &observedUnfinishedVerificationRevisions, + observedFailures: + &observedUnfinishedVerificationFailures + ) guard !postQueryBatch.acceptedTransactions.isEmpty else { await reportVerificationFailures( - unfinished: postQueryBatch.verificationFailures, + unfinished: observedUnfinishedVerificationFailures, currentEntitlements: result.verificationFailures ) return result.snapshots @@ -102,7 +128,7 @@ package final class CurrentEntitlementReconciler: Sendable { ) async -> UnfinishedBatch { var revisions: Set = [] var acceptedTransactions: [AcceptedTransaction] = [] - var verificationFailures: [any Error] = [] + var verificationFailures: [UnfinishedVerificationFailure] = [] for delivery in await queryUnfinished() { switch delivery { @@ -118,8 +144,12 @@ package final class CurrentEntitlementReconciler: Sendable { snapshot: envelope.value, acceptance: await core.accept(envelope) )) - case .unverified(let error): - verificationFailures.append(error) + case .unverified(let revision, let error): + verificationFailures.append( + UnfinishedVerificationFailure( + revision: revision, + error: error + )) } } @@ -130,6 +160,17 @@ package final class CurrentEntitlementReconciler: Sendable { ) } + private func collectUnfinishedVerificationFailures( + _ verificationFailures: [UnfinishedVerificationFailure], + observedRevisions: inout Set, + observedFailures: inout [any Error] + ) { + for failure in verificationFailures + where observedRevisions.insert(failure.revision).inserted { + observedFailures.append(failure.error) + } + } + private func reportVerificationFailures( unfinished: [any Error], currentEntitlements: [StoreTransactionVerificationError] diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift index 16fb67e..0f89ef7 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionPipeline.swift @@ -28,7 +28,7 @@ package final class StoreTransactionPipeline: Sendable { await core.accept(envelope), retryFailedTransactions ) - case .unverified(let error): + case .unverified(_, let error): throw error } } diff --git a/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift b/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift index 8957010..babcf92 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/LiveTransactionAdapter.swift @@ -21,7 +21,8 @@ package enum LiveTransactionAdapter { )) case .unverified(_, let error): return .unverified( - StoreTransactionVerificationError( + revision: Data(result.jwsRepresentation.utf8), + error: StoreTransactionVerificationError( underlyingError: error )) } diff --git a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift index f9b3591..c9e8f40 100644 --- a/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift +++ b/Sources/StoreTransactionKit/StoreKitSource/StoreTransactionSource.swift @@ -1,8 +1,9 @@ +import Foundation import StoreKit package enum StoreTransactionDelivery: Sendable { case verified(ProcessingEnvelope) - case unverified(any Error) + case unverified(revision: Data, error: any Error) } package struct CurrentEntitlementQueryResult: Sendable { diff --git a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift index 257a547..e28ac42 100644 --- a/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift +++ b/Tests/StoreTransactionKitTests/ReconciliationFixedPointTests.swift @@ -341,7 +341,8 @@ struct ReconciliationFixedPointTests { let finishes = TestSignal() let reports = StringRecorder() let unverified = StoreTransactionDelivery.unverified( - PersistentUnfinishedVerificationFailure() + revision: Data("persistent-unverified".utf8), + error: PersistentUnfinishedVerificationFailure() ) let verified = StoreTransactionDelivery.verified( makeEnvelope(snapshot: snapshot) { @@ -397,6 +398,64 @@ struct ReconciliationFixedPointTests { await failures.sealAndDrain() } + @Test("an observed unverified unfinished delivery is reported after it disappears") + func disappearingUnverifiedUnfinishedIsReported() async throws { + let snapshot = makeSnapshot( + id: 49, + productID: "lifetime.disappearing-verification", + productType: .nonConsumable, + jws: "disappearing-unfinished-verification" + ) + let current = EntitlementValueSource([]) + let unfinished = UnfinishedValueSource() + let reports = StringRecorder() + let unverified = StoreTransactionDelivery.unverified( + revision: Data("disappearing-unverified".utf8), + error: DisappearingUnfinishedVerificationFailure() + ) + let verified = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await current.replace(with: [snapshot]) + await unfinished.replace(with: []) + } + ) + await unfinished.replace(with: [unverified, verified]) + + let core = TransactionProcessingCore { _ in } + let failures = FailureReporterDispatcher { failure in + guard failure.source == .unfinished, + failure.transactionID == nil, + failure.productID == nil, + failure.underlyingError + is DisappearingUnfinishedVerificationFailure + else { + await reports.append("unexpected") + return + } + await reports.append("unverified") + } + let reconciler = CurrentEntitlementReconciler( + query: { + CurrentEntitlementQueryResult( + snapshots: await current.read(), + verificationFailures: [] + ) + }, + queryUnfinished: { await unfinished.read() }, + core: core, + failures: failures + ) + + let snapshots = try await reconciler.query( + retryFailedTransactions: false + ) + + #expect(snapshots == [snapshot]) + #expect(await reports.snapshot() == ["unverified"]) + await core.finishInputAndDrain() + await failures.sealAndDrain() + } + @Test("a terminal handler failure preserves verification diagnostics") func handlerFailurePreservesVerificationDiagnostics() async throws { let snapshot = makeSnapshot( @@ -457,7 +516,10 @@ struct ReconciliationFixedPointTests { return [] } return [ - .unverified(TerminalUnfinishedVerificationFailure()), + .unverified( + revision: Data("terminal-unverified".utf8), + error: TerminalUnfinishedVerificationFailure() + ), .verified( makeEnvelope(snapshot: snapshot) { await finishes.send() @@ -497,6 +559,8 @@ private struct StableVerificationFailure: Error, Sendable {} private struct PersistentUnfinishedVerificationFailure: Error, Sendable {} +private struct DisappearingUnfinishedVerificationFailure: Error, Sendable {} + private struct TerminalUnfinishedVerificationFailure: Error, Sendable {} private struct TerminalCurrentVerificationFailure: Error, Sendable {} diff --git a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift index a3b0e99..851ea13 100644 --- a/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift +++ b/Tests/StoreTransactionKitTests/StoreTransactionSessionTests.swift @@ -740,7 +740,11 @@ struct StoreTransactionSessionTests { await finished.send() })) try await finished.wait(for: 1) - fixture.updates.yield(.unverified(TestFailure())) + fixture.updates.yield( + .unverified( + revision: Data("update-verification-failure".utf8), + error: TestFailure() + )) try await failureReported.wait(for: 1) #expect(