From 873385b47fcf4c944edacf7826e70e666baf1edd Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 06:41:42 +0900 Subject: [PATCH 1/8] fix(apple): serialize connection teardown Closes #140 --- packages/apple/Sources/OpenIapModule.swift | 450 +++++++++++++----- .../OpenIapTests/OpenIapProviderTests.swift | 19 +- 2 files changed, 338 insertions(+), 131 deletions(-) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 313922e5..9d1df9a1 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -23,9 +23,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private var updateListenerTask: Task? private var messageListenerTask: Task? + private var unfinishedTransactionTask: Task? private var productManager: ProductManager? private let state = IapState() private var initTask: Task? + private var initTaskGeneration: UInt64? + private var cancellingInitTask: Task? + private var connectionGeneration: UInt64 = 0 + private let connectionLock = NSLock() private static let subscriptionPreflightTimeoutNanoseconds: UInt64 = 750_000_000 private enum SubscriptionPreflightOutcome { @@ -33,6 +38,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { case timedOut } + private typealias ConnectionCleanupResources = ( + updateListenerTask: Task?, + messageListenerTask: Task?, + unfinishedTransactionTask: Task?, + productManager: ProductManager?, + didRegisterPaymentQueueObserver: Bool + ) + // iOS-only: SKPaymentQueue observer for promoted in-app purchases // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases #if os(iOS) @@ -44,8 +57,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } deinit { - updateListenerTask?.cancel() - messageListenerTask?.cancel() + cancelConnectionTasksForDeinit() } // MARK: - Connection Management @@ -59,58 +71,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// /// See: https://www.openiap.dev/docs/apis/init-connection public func initConnection() async throws -> Bool { - if await state.isInitialized { - return true - } - - if let task = initTask { - return try await task.value - } - - let task = Task { [weak self] () -> Bool in - guard let self else { return false } - - if await self.state.isInitialized { - return true - } - - if self.productManager == nil { - self.productManager = ProductManager() - } - - // iOS-only: Register SKPaymentQueue observer for promoted in-app purchases - // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases - #if os(iOS) - if !self.didRegisterPaymentQueueObserver { - await MainActor.run { - SKPaymentQueue.default().add(self) - } - self.didRegisterPaymentQueueObserver = true - } - #endif // os(iOS) - - guard AppStore.canMakePayments else { - self.emitPurchaseError(self.makePurchaseError(code: .iapNotAvailable)) - await self.state.setInitialized(false) - return false - } - - await self.state.setInitialized(true) - self.startTransactionListener() - Task { [weak self] in - guard let self else { return } - await self.processUnfinishedTransactions() - } - return true - } - initTask = task + let (task, generation) = makeInitConnectionTask() do { let value = try await task.value - initTask = nil + clearInitConnectionTask(generation: generation) return value } catch { - initTask = nil + clearInitConnectionTask(generation: generation) throw error } } @@ -118,9 +86,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// Close the store connection and release resources. /// See: https://www.openiap.dev/docs/apis/end-connection public func endConnection() async throws -> Bool { - initTask?.cancel() - initTask = nil - await cleanupExistingState() + let (task, generation) = cancelInitConnectionTaskForEnd() + task?.cancel() + if let task { + _ = try? await task.value + } + await cleanupExistingState(generation: generation) return true } @@ -146,7 +117,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } try await ensureConnection() - guard let productManager else { + guard let productManager = currentProductManager() else { let error = makePurchaseError(code: .notPrepared) emitPurchaseError(error) throw error @@ -1424,6 +1395,195 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Private Helpers + private func withConnectionLock(_ body: () throws -> T) rethrows -> T { + connectionLock.lock() + defer { connectionLock.unlock() } + return try body() + } + + private func makeInitConnectionTask() -> (task: Task, generation: UInt64) { + withConnectionLock { + if let initTask, initTaskGeneration == connectionGeneration { + return (initTask, connectionGeneration) + } + + connectionGeneration += 1 + let generation = connectionGeneration + let task = Task { [weak self] in + guard let self else { return false } + return try await self.performInitConnection(generation: generation) + } + initTask = task + initTaskGeneration = generation + cancellingInitTask = nil + return (task, generation) + } + } + + private func performInitConnection(generation: UInt64) async throws -> Bool { + try Task.checkCancellation() + try ensureCurrentConnectionGeneration(generation) + + if await state.isInitialized { + return true + } + + _ = try getOrCreateProductManager(generation: generation) + + // iOS-only: Register SKPaymentQueue observer for promoted in-app purchases + // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases + #if os(iOS) + if try markPaymentQueueObserverRegisteredIfNeeded(generation: generation) { + try Task.checkCancellation() + await MainActor.run { + SKPaymentQueue.default().add(self) + } + } + #endif // os(iOS) + + try Task.checkCancellation() + try ensureCurrentConnectionGeneration(generation) + + guard AppStore.canMakePayments else { + emitPurchaseError(makePurchaseError(code: .iapNotAvailable)) + await state.setInitialized(false) + return false + } + + try Task.checkCancellation() + try ensureCurrentConnectionGeneration(generation) + + await state.setInitialized(true) + try startTransactionListener(generation: generation) + try startUnfinishedTransactionProcessing(generation: generation) + return true + } + + private func cancelInitConnectionTaskForEnd() -> (task: Task?, generation: UInt64) { + withConnectionLock { + connectionGeneration += 1 + let generation = connectionGeneration + let task = initTask ?? cancellingInitTask + if let initTask { + cancellingInitTask = initTask + } + initTask = nil + initTaskGeneration = nil + return (task, generation) + } + } + + private func clearInitConnectionTask(generation: UInt64) { + withConnectionLock { + if initTaskGeneration == generation { + initTask = nil + initTaskGeneration = nil + } + } + } + + private func ensureCurrentConnectionGeneration(_ generation: UInt64) throws { + try withConnectionLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + } + } + + private func getOrCreateProductManager(generation: UInt64) throws -> ProductManager { + try withConnectionLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + + if productManager == nil { + productManager = ProductManager() + } + + guard let productManager else { + throw CancellationError() + } + return productManager + } + } + + private func currentProductManager() -> ProductManager? { + withConnectionLock { + productManager + } + } + + #if os(iOS) + private func markPaymentQueueObserverRegisteredIfNeeded(generation: UInt64) throws -> Bool { + try withConnectionLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + + if didRegisterPaymentQueueObserver { + return false + } + + didRegisterPaymentQueueObserver = true + return true + } + } + #endif + + private func detachConnectionResourcesForCleanup(generation: UInt64) -> ConnectionCleanupResources? { + withConnectionLock { + guard connectionGeneration == generation else { + return nil + } + + #if os(iOS) + let wasRegistered = self.didRegisterPaymentQueueObserver + self.didRegisterPaymentQueueObserver = false + #else + let wasRegistered = false + #endif + + let resources = ConnectionCleanupResources( + updateListenerTask: updateListenerTask, + messageListenerTask: messageListenerTask, + unfinishedTransactionTask: unfinishedTransactionTask, + productManager: productManager, + didRegisterPaymentQueueObserver: wasRegistered + ) + + updateListenerTask = nil + messageListenerTask = nil + unfinishedTransactionTask = nil + productManager = nil + return resources + } + } + + private func cancelConnectionTasksForDeinit() { + let resources = withConnectionLock { + let resources = ( + initTask: initTask, + cancellingInitTask: cancellingInitTask, + updateListenerTask: updateListenerTask, + messageListenerTask: messageListenerTask, + unfinishedTransactionTask: unfinishedTransactionTask + ) + initTask = nil + initTaskGeneration = nil + cancellingInitTask = nil + updateListenerTask = nil + messageListenerTask = nil + unfinishedTransactionTask = nil + return resources + } + + resources.initTask?.cancel() + resources.cancellingInitTask?.cancel() + resources.updateListenerTask?.cancel() + resources.messageListenerTask?.cancel() + resources.unfinishedTransactionTask?.cancel() + } + private func ensureConnection() async throws { if await state.isInitialized == false { _ = try await initConnection() @@ -1442,28 +1602,29 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } } - private func cleanupExistingState() async { - updateListenerTask?.cancel() - updateListenerTask = nil - messageListenerTask?.cancel() - messageListenerTask = nil + private func cleanupExistingState(generation: UInt64) async { + guard let resources = detachConnectionResourcesForCleanup(generation: generation) else { + return + } + + resources.updateListenerTask?.cancel() + resources.messageListenerTask?.cancel() + resources.unfinishedTransactionTask?.cancel() await state.reset() // iOS-only: Remove SKPaymentQueue observer for promoted in-app purchases // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases #if os(iOS) - if didRegisterPaymentQueueObserver { + if resources.didRegisterPaymentQueueObserver { await MainActor.run { SKPaymentQueue.default().remove(self) } - didRegisterPaymentQueueObserver = false } #endif // os(iOS) - if let manager = productManager { await manager.removeAll() } - productManager = nil + if let manager = resources.productManager { await manager.removeAll() } } private func storeProduct(for sku: String) async throws -> StoreKit.Product { - guard let productManager else { + guard let productManager = currentProductManager() else { let error = makePurchaseError(code: .notPrepared) emitPurchaseError(error) throw error @@ -1499,75 +1660,101 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { throw makePurchaseError(code: .purchaseError, message: "Missing iOS purchase parameters") } - private func startTransactionListener() { - if updateListenerTask != nil { - return - } + private func startTransactionListener(generation: UInt64) throws { + try withConnectionLock { + guard connectionGeneration == generation else { + throw CancellationError() + } - OpenIapLog.debug("๐ŸŽง [TransactionListener] Starting Transaction.updates listener...") - updateListenerTask = Task { [weak self] in - guard let self else { - OpenIapLog.debug("โš ๏ธ [TransactionListener] Self is nil, exiting listener") + if updateListenerTask != nil { return } - OpenIapLog.debug("โœ… [TransactionListener] Listener task started, waiting for transactions...") - for await verification in Transaction.updates { - do { - guard await self.state.isInitialized else { continue } - let transaction = try self.checkVerified(verification) - let transactionId = String(transaction.id) - - // Log all transaction details for debugging - OpenIapLog.debug(""" - ๐Ÿ“ฆ Transaction received: - - ID: \(transactionId) - - Product: \(transaction.productID) - - purchaseDate: \(transaction.purchaseDate) - - subscriptionGroupID: \(transaction.subscriptionGroupID ?? "nil") - - revocationDate: \(transaction.revocationDate?.description ?? "nil") - """) - if transaction.productType == .autoRenewable, - self.isInactiveSubscriptionTransaction(transaction) { - await transaction.finish() - await self.state.removePending(id: transactionId) + OpenIapLog.debug("๐ŸŽง [TransactionListener] Starting Transaction.updates listener...") + updateListenerTask = Task { [weak self] in + guard let self else { + OpenIapLog.debug("โš ๏ธ [TransactionListener] Self is nil, exiting listener") + return + } + OpenIapLog.debug("โœ… [TransactionListener] Listener task started, waiting for transactions...") + for await verification in Transaction.updates { + if Task.isCancelled { return } + do { + guard await self.state.isInitialized else { continue } + let transaction = try self.checkVerified(verification) + let transactionId = String(transaction.id) + + // Log all transaction details for debugging OpenIapLog.debug(""" - ๐Ÿงน [TransactionListener] Finished inactive subscription update without emitting: - - SKU: \(transaction.productID) - - Transaction ID: \(transaction.id) - - Expiration: \(transaction.expirationDate?.description ?? "none") - - Revoked: \(transaction.revocationDate?.description ?? "none") - - Upgraded: \(transaction.isUpgraded) + ๐Ÿ“ฆ Transaction received: + - ID: \(transactionId) + - Product: \(transaction.productID) + - purchaseDate: \(transaction.purchaseDate) + - subscriptionGroupID: \(transaction.subscriptionGroupID ?? "nil") + - revocationDate: \(transaction.revocationDate?.description ?? "nil") """) - continue - } - if transaction.revocationDate != nil { - OpenIapLog.debug("โญ๏ธ Skipping revoked transaction: \(transactionId)") - continue + if transaction.productType == .autoRenewable, + self.isInactiveSubscriptionTransaction(transaction) { + await transaction.finish() + await self.state.removePending(id: transactionId) + OpenIapLog.debug(""" + ๐Ÿงน [TransactionListener] Finished inactive subscription update without emitting: + - SKU: \(transaction.productID) + - Transaction ID: \(transaction.id) + - Expiration: \(transaction.expirationDate?.description ?? "none") + - Revoked: \(transaction.revocationDate?.description ?? "none") + - Upgraded: \(transaction.isUpgraded) + """) + continue + } + + if transaction.revocationDate != nil { + OpenIapLog.debug("โญ๏ธ Skipping revoked transaction: \(transactionId)") + continue + } + + // Store pending and emit + await self.state.storePending(id: transactionId, transaction: transaction) + let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) + + OpenIapLog.debug("โœ… [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)") + self.emitPurchaseUpdate(purchase) + } catch { + let purchaseError: PurchaseError + if let existing = error as? PurchaseError { + purchaseError = existing + } else { + purchaseError = makePurchaseError(code: .transactionValidationFailed, message: error.localizedDescription) + } + self.emitPurchaseError(purchaseError) } + } + } + } + } - // Store pending and emit - await self.state.storePending(id: transactionId, transaction: transaction) - let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation) + private func startUnfinishedTransactionProcessing(generation: UInt64) throws { + try withConnectionLock { + guard connectionGeneration == generation else { + throw CancellationError() + } - OpenIapLog.debug("โœ… [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)") - self.emitPurchaseUpdate(purchase) - } catch { - let purchaseError: PurchaseError - if let existing = error as? PurchaseError { - purchaseError = existing - } else { - purchaseError = makePurchaseError(code: .transactionValidationFailed, message: error.localizedDescription) - } - self.emitPurchaseError(purchaseError) - } + if unfinishedTransactionTask != nil { + return + } + + unfinishedTransactionTask = Task { [weak self] in + guard let self else { return } + await self.processUnfinishedTransactions() } } } private func processUnfinishedTransactions() async { for await verification in Transaction.unfinished { + if Task.isCancelled { return } + guard await state.isInitialized else { return } do { let transaction = try checkVerified(verification) if transaction.productType == .autoRenewable, isInactiveSubscriptionTransaction(transaction) { @@ -1721,22 +1908,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private func startMessageListener() { #if os(iOS) || targetEnvironment(macCatalyst) if #available(iOS 18.0, macCatalyst 18.0, *) { - messageListenerTask?.cancel() OpenIapLog.debug("๐Ÿ”” [MessageListener] Starting Message.messages listener (iOS 18+)") - messageListenerTask = Task { [weak self] in - guard let self else { return } - for await message in StoreKit.Message.messages { - OpenIapLog.debug("๐Ÿ”” [MessageListener] Received message: reason=\(message.reason)") - guard await self.state.isInitialized else { - OpenIapLog.debug("๐Ÿ”” [MessageListener] Skipping โ€” not initialized") - continue - } - guard case .billingIssue = message.reason else { - OpenIapLog.debug("๐Ÿ”” [MessageListener] Skipping non-billingIssue message") - continue + withConnectionLock { + messageListenerTask?.cancel() + messageListenerTask = Task { [weak self] in + guard let self else { return } + for await message in StoreKit.Message.messages { + if Task.isCancelled { return } + OpenIapLog.debug("๐Ÿ”” [MessageListener] Received message: reason=\(message.reason)") + guard await self.state.isInitialized else { + OpenIapLog.debug("๐Ÿ”” [MessageListener] Skipping โ€” not initialized") + continue + } + guard case .billingIssue = message.reason else { + OpenIapLog.debug("๐Ÿ”” [MessageListener] Skipping non-billingIssue message") + continue + } + OpenIapLog.debug("๐Ÿ”” [MessageListener] billingIssue received โ€” dispatching") + await self.dispatchBillingIssueMessage() } - OpenIapLog.debug("๐Ÿ”” [MessageListener] billingIssue received โ€” dispatching") - await self.dispatchBillingIssueMessage() } } } else { diff --git a/packages/apple/Tests/OpenIapTests/OpenIapProviderTests.swift b/packages/apple/Tests/OpenIapTests/OpenIapProviderTests.swift index 78e5a1d8..f037c9f4 100644 --- a/packages/apple/Tests/OpenIapTests/OpenIapProviderTests.swift +++ b/packages/apple/Tests/OpenIapTests/OpenIapProviderTests.swift @@ -102,6 +102,23 @@ final class OpenIapProviderTests: XCTestCase { // All listeners should have been cleaned up automatically } + func testConcurrentInitAndEndConnectionDoesNotCrash() async throws { + let module = OpenIapModule.shared + + await withTaskGroup(of: Void.self) { group in + for _ in 0..<20 { + group.addTask { + _ = try? await module.initConnection() + } + group.addTask { + _ = try? await module.endConnection() + } + } + } + + _ = try? await module.endConnection() + } + // MARK: - Introductory Offer Eligibility Tests @MainActor @@ -182,4 +199,4 @@ final class OpenIapProviderTests: XCTestCase { // Clean up try await store.endConnection() } -} \ No newline at end of file +} From 73cb694082f121d0b3b4fe9692ea970696862f52 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 06:47:44 +0900 Subject: [PATCH 2/8] fix(apple): reduce connection task churn --- packages/apple/Sources/OpenIapModule.swift | 115 ++++++++++++++------- 1 file changed, 77 insertions(+), 38 deletions(-) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 9d1df9a1..d8fc631e 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -28,7 +28,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private let state = IapState() private var initTask: Task? private var initTaskGeneration: UInt64? - private var cancellingInitTask: Task? + private var endTask: Task? + private var endTaskGeneration: UInt64? private var connectionGeneration: UInt64 = 0 private let connectionLock = NSLock() private static let subscriptionPreflightTimeoutNanoseconds: UInt64 = 750_000_000 @@ -71,27 +72,37 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// /// See: https://www.openiap.dev/docs/apis/init-connection public func initConnection() async throws -> Bool { - let (task, generation) = makeInitConnectionTask() + while true { + if let endTask = currentEndConnectionTask() { + await endTask.value + continue + } - do { - let value = try await task.value - clearInitConnectionTask(generation: generation) - return value - } catch { - clearInitConnectionTask(generation: generation) - throw error + if await state.isInitialized { + return true + } + + guard let (task, generation) = makeInitConnectionTask() else { + await Task.yield() + continue + } + + do { + let value = try await task.value + clearInitConnectionTask(generation: generation) + return value + } catch { + clearInitConnectionTask(generation: generation) + throw error + } } } /// Close the store connection and release resources. /// See: https://www.openiap.dev/docs/apis/end-connection public func endConnection() async throws -> Bool { - let (task, generation) = cancelInitConnectionTaskForEnd() - task?.cancel() - if let task { - _ = try? await task.value - } - await cleanupExistingState(generation: generation) + let task = makeEndConnectionTask() + await task.value return true } @@ -1401,8 +1412,18 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return try body() } - private func makeInitConnectionTask() -> (task: Task, generation: UInt64) { + private func currentEndConnectionTask() -> Task? { withConnectionLock { + endTask + } + } + + private func makeInitConnectionTask() -> (task: Task, generation: UInt64)? { + withConnectionLock { + guard endTask == nil else { + return nil + } + if let initTask, initTaskGeneration == connectionGeneration { return (initTask, connectionGeneration) } @@ -1415,7 +1436,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } initTask = task initTaskGeneration = generation - cancellingInitTask = nil return (task, generation) } } @@ -1459,17 +1479,30 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return true } - private func cancelInitConnectionTaskForEnd() -> (task: Task?, generation: UInt64) { + private func makeEndConnectionTask() -> Task { withConnectionLock { + if let endTask { + return endTask + } + connectionGeneration += 1 let generation = connectionGeneration - let task = initTask ?? cancellingInitTask - if let initTask { - cancellingInitTask = initTask - } + let taskToCancel = initTask initTask = nil initTaskGeneration = nil - return (task, generation) + let task = Task { [weak self] in + taskToCancel?.cancel() + if let taskToCancel { + _ = try? await taskToCancel.value + } + + guard let self else { return } + await self.cleanupExistingState() + self.clearEndConnectionTask(generation: generation) + } + endTask = task + endTaskGeneration = generation + return task } } @@ -1482,6 +1515,15 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } } + private func clearEndConnectionTask(generation: UInt64) { + withConnectionLock { + if endTaskGeneration == generation { + endTask = nil + endTaskGeneration = nil + } + } + } + private func ensureCurrentConnectionGeneration(_ generation: UInt64) throws { try withConnectionLock { guard connectionGeneration == generation else { @@ -1530,12 +1572,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } #endif - private func detachConnectionResourcesForCleanup(generation: UInt64) -> ConnectionCleanupResources? { + private func detachConnectionResourcesForCleanup() -> ConnectionCleanupResources { withConnectionLock { - guard connectionGeneration == generation else { - return nil - } - #if os(iOS) let wasRegistered = self.didRegisterPaymentQueueObserver self.didRegisterPaymentQueueObserver = false @@ -1563,14 +1601,15 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { let resources = withConnectionLock { let resources = ( initTask: initTask, - cancellingInitTask: cancellingInitTask, + endTask: endTask, updateListenerTask: updateListenerTask, messageListenerTask: messageListenerTask, unfinishedTransactionTask: unfinishedTransactionTask ) initTask = nil initTaskGeneration = nil - cancellingInitTask = nil + endTask = nil + endTaskGeneration = nil updateListenerTask = nil messageListenerTask = nil unfinishedTransactionTask = nil @@ -1578,7 +1617,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } resources.initTask?.cancel() - resources.cancellingInitTask?.cancel() + resources.endTask?.cancel() resources.updateListenerTask?.cancel() resources.messageListenerTask?.cancel() resources.unfinishedTransactionTask?.cancel() @@ -1602,11 +1641,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } } - private func cleanupExistingState(generation: UInt64) async { - guard let resources = detachConnectionResourcesForCleanup(generation: generation) else { - return - } - + private func cleanupExistingState() async { + let resources = detachConnectionResourcesForCleanup() resources.updateListenerTask?.cancel() resources.messageListenerTask?.cancel() resources.unfinishedTransactionTask?.cancel() @@ -1908,9 +1944,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private func startMessageListener() { #if os(iOS) || targetEnvironment(macCatalyst) if #available(iOS 18.0, macCatalyst 18.0, *) { - OpenIapLog.debug("๐Ÿ”” [MessageListener] Starting Message.messages listener (iOS 18+)") withConnectionLock { - messageListenerTask?.cancel() + if messageListenerTask != nil { + return + } + + OpenIapLog.debug("๐Ÿ”” [MessageListener] Starting Message.messages listener (iOS 18+)") messageListenerTask = Task { [weak self] in guard let self else { return } for await message in StoreKit.Message.messages { From 151f624f81a198aae9151c74e0dd9f8487da6cdd Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 06:57:51 +0900 Subject: [PATCH 3/8] fix(apple): restore listener after reconnect --- packages/apple/Sources/Helpers/IapState.swift | 3 +++ packages/apple/Sources/OpenIapModule.swift | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift index 33715621..f88bcaab 100644 --- a/packages/apple/Sources/Helpers/IapState.swift +++ b/packages/apple/Sources/Helpers/IapState.swift @@ -88,4 +88,7 @@ actor IapState { func snapshotSubscriptionBillingIssue() -> [SubscriptionBillingIssueListener] { subscriptionBillingIssueListeners.map { $0.listener } } + func hasSubscriptionBillingIssueListeners() -> Bool { + !subscriptionBillingIssueListeners.isEmpty + } } diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index d8fc631e..35696d5a 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -91,6 +91,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { let value = try await task.value clearInitConnectionTask(generation: generation) return value + } catch is CancellationError { + clearInitConnectionTask(generation: generation) + return false } catch { clearInitConnectionTask(generation: generation) throw error @@ -1476,6 +1479,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { await state.setInitialized(true) try startTransactionListener(generation: generation) try startUnfinishedTransactionProcessing(generation: generation) + if await state.hasSubscriptionBillingIssueListeners() { + startMessageListener() + } return true } @@ -1624,6 +1630,10 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } private func ensureConnection() async throws { + if let endTask = currentEndConnectionTask() { + await endTask.value + } + if await state.isInitialized == false { _ = try await initConnection() } @@ -1945,7 +1955,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { #if os(iOS) || targetEnvironment(macCatalyst) if #available(iOS 18.0, macCatalyst 18.0, *) { withConnectionLock { - if messageListenerTask != nil { + if endTask != nil || messageListenerTask != nil { return } From 2586d7626bb631b733296234ac6502d6e464c9f9 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 06:58:04 +0900 Subject: [PATCH 4/8] docs(releases): add apple teardown patch notes --- .../docs/src/pages/docs/updates/releases.tsx | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 9624739c..0cd3554c 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -26,6 +26,175 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // May 8, 2026 โ€” openiap-apple + framework SDK iOS connection teardown patches + { + id: 'apple-2-1-7-framework-ios-connection-teardown-patches', + date: new Date('2026-05-08'), + element: ( +
+ + May 8, 2026 โ€” openiap-apple + framework SDK iOS connection teardown + patches + + +

+ Publishes openiap-apple 2.1.7 and framework-library + patch releases for an iOS lifecycle race where{' '} + endConnection() could + run while{' '} + initConnection() was + still preparing StoreKit resources. The crash was reported from an{' '} + expo-iap unmount path, but the + shared Apple runtime is consumed by all framework SDKs, so the + native Apple patch and six framework patches are released together. + See{' '} + + issue #140 + {' '} + and{' '} + + PR #142 + + . +

+ +
    +
  • + iOS lifecycle fix โ€” connection teardown now + cancels and waits for in-flight initialization before clearing + listener tasks, pending StoreKit work, product cache state, and + promoted-purchase observer registration. +
  • +
  • + Unmount-safe cleanup โ€” duplicate cleanup calls + from JS hooks and native module destruction share the same + teardown path, reducing the crash window on physical iOS devices. +
  • +
  • + Listener stability โ€” subscription billing issue + listeners restore the StoreKit message stream after reconnects + while avoiding duplicate stream tasks when one is already active. +
  • +
  • + No API changes โ€” app code can keep calling{' '} + initConnection() and endConnection() the + same way; direct SPM/CocoaPods consumers should upgrade + openiap-apple, and framework consumers should upgrade their + wrapper package. +
  • +
+ + {/* Package Releases */} + +
+ ), + }, + // May 6, 2026 โ€” maui-iap 1.0.0 published { id: 'maui-iap-1-0-0', From 80b463ac7971701f0c579b80c8ad55cb691b4953 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 07:08:29 +0900 Subject: [PATCH 5/8] fix(apple): guard connection task completion --- packages/apple/Sources/OpenIapModule.swift | 31 +++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 35696d5a..2766b044 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -1479,9 +1479,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { await state.setInitialized(true) try startTransactionListener(generation: generation) try startUnfinishedTransactionProcessing(generation: generation) - if await state.hasSubscriptionBillingIssueListeners() { - startMessageListener() + let hasSubscriptionBillingIssueListeners = await state.hasSubscriptionBillingIssueListeners() + try Task.checkCancellation() + try ensureCurrentConnectionGeneration(generation) + if hasSubscriptionBillingIssueListeners { + try startMessageListener(generation: generation) } + try Task.checkCancellation() + try ensureCurrentConnectionGeneration(generation) return true } @@ -1530,6 +1535,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } } + private func clearUnfinishedTransactionTask(generation: UInt64) { + withConnectionLock { + if connectionGeneration == generation { + unfinishedTransactionTask = nil + } + } + } + private func ensureCurrentConnectionGeneration(_ generation: UInt64) throws { try withConnectionLock { guard connectionGeneration == generation else { @@ -1792,6 +1805,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { unfinishedTransactionTask = Task { [weak self] in guard let self else { return } + defer { self.clearUnfinishedTransactionTask(generation: generation) } await self.processUnfinishedTransactions() } } @@ -1952,9 +1966,20 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - https://developer.apple.com/documentation/storekit/message /// - https://developer.apple.com/documentation/storekit/message/reason-swift.struct/billingissue private func startMessageListener() { + try? startMessageListener(requiringGeneration: nil) + } + + private func startMessageListener(generation: UInt64) throws { + try startMessageListener(requiringGeneration: generation) + } + + private func startMessageListener(requiringGeneration generation: UInt64?) throws { #if os(iOS) || targetEnvironment(macCatalyst) if #available(iOS 18.0, macCatalyst 18.0, *) { - withConnectionLock { + try withConnectionLock { + if let generation, connectionGeneration != generation { + throw CancellationError() + } if endTask != nil || messageListenerTask != nil { return } From 2aba861854f52ae3ef3cbea015a30247c40cb557 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 07:20:05 +0900 Subject: [PATCH 6/8] refactor(apple): extract connection lifecycle state --- .../Helpers/OpenIapConnectionLifecycle.swift | 276 +++++++++++++++++ packages/apple/Sources/OpenIapModule.swift | 289 +++--------------- 2 files changed, 312 insertions(+), 253 deletions(-) create mode 100644 packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift diff --git a/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift b/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift new file mode 100644 index 00000000..40f79ad4 --- /dev/null +++ b/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift @@ -0,0 +1,276 @@ +import Foundation + +/// Owns the mutable connection resources that must move together during +/// init/end races. OpenIapModule keeps StoreKit behavior; this helper keeps the +/// lock, generation checks, and task handles in one place. +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +final class OpenIapConnectionLifecycle { + // MARK: - Resource Snapshots + + struct CleanupResources { + let updateListenerTask: Task? + let messageListenerTask: Task? + let unfinishedTransactionTask: Task? + let productManager: ProductManager? + let didRegisterPaymentQueueObserver: Bool + } + + struct DeinitResources { + let initTask: Task? + let endTask: Task? + let updateListenerTask: Task? + let messageListenerTask: Task? + let unfinishedTransactionTask: Task? + } + + private let lock = NSLock() + private var connectionGeneration: UInt64 = 0 + private var initTask: Task? + private var initTaskGeneration: UInt64? + private var endTask: Task? + private var endTaskGeneration: UInt64? + private var updateListenerTask: Task? + private var messageListenerTask: Task? + private var unfinishedTransactionTask: Task? + private var productManager: ProductManager? + + #if os(iOS) + private var didRegisterPaymentQueueObserver = false + #endif + + // MARK: - Init / End Tasks + + func currentEndTask() -> Task? { + withLock { endTask } + } + + func makeInitTask( + operation: @escaping (UInt64) async throws -> Bool + ) -> (task: Task, generation: UInt64)? { + withLock { + guard endTask == nil else { + return nil + } + + if let initTask, initTaskGeneration == connectionGeneration { + return (initTask, connectionGeneration) + } + + connectionGeneration += 1 + let generation = connectionGeneration + let task = Task { + try await operation(generation) + } + initTask = task + initTaskGeneration = generation + return (task, generation) + } + } + + func makeEndTask(cleanup: @escaping () async -> Void) -> Task { + withLock { + if let endTask { + return endTask + } + + connectionGeneration += 1 + let generation = connectionGeneration + let taskToCancel = initTask + initTask = nil + initTaskGeneration = nil + + let task = Task { [weak self] in + taskToCancel?.cancel() + if let taskToCancel { + _ = try? await taskToCancel.value + } + + await cleanup() + self?.clearEndTask(generation: generation) + } + endTask = task + endTaskGeneration = generation + return task + } + } + + func clearInitTask(generation: UInt64) { + withLock { + if initTaskGeneration == generation { + initTask = nil + initTaskGeneration = nil + } + } + } + + func clearUnfinishedTransactionTask(generation: UInt64) { + withLock { + if connectionGeneration == generation { + unfinishedTransactionTask = nil + } + } + } + + func ensureCurrent(_ generation: UInt64) throws { + try withLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + } + } + + // MARK: - Resource Access + + func currentProductManager() -> ProductManager? { + withLock { productManager } + } + + func getOrCreateProductManager(generation: UInt64) throws -> ProductManager { + try withLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + + if productManager == nil { + productManager = ProductManager() + } + + guard let productManager else { + throw CancellationError() + } + return productManager + } + } + + #if os(iOS) + func markPaymentQueueObserverRegisteredIfNeeded(generation: UInt64) throws -> Bool { + try withLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + + if didRegisterPaymentQueueObserver { + return false + } + + didRegisterPaymentQueueObserver = true + return true + } + } + #endif + + // MARK: - Listener Tasks + + func startTransactionListenerTask( + generation: UInt64, + makeTask: () -> Task + ) throws { + try withLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + guard updateListenerTask == nil else { + return + } + + updateListenerTask = makeTask() + } + } + + func startUnfinishedTransactionTask( + generation: UInt64, + makeTask: () -> Task + ) throws { + try withLock { + guard connectionGeneration == generation else { + throw CancellationError() + } + guard unfinishedTransactionTask == nil else { + return + } + + unfinishedTransactionTask = makeTask() + } + } + + func startMessageListenerTask( + generation: UInt64?, + makeTask: () -> Task + ) throws { + try withLock { + if let generation, connectionGeneration != generation { + throw CancellationError() + } + guard endTask == nil, messageListenerTask == nil else { + return + } + + messageListenerTask = makeTask() + } + } + + // MARK: - Cleanup + + func detachResourcesForCleanup() -> CleanupResources { + withLock { + #if os(iOS) + let wasRegistered = didRegisterPaymentQueueObserver + didRegisterPaymentQueueObserver = false + #else + let wasRegistered = false + #endif + + let resources = CleanupResources( + updateListenerTask: updateListenerTask, + messageListenerTask: messageListenerTask, + unfinishedTransactionTask: unfinishedTransactionTask, + productManager: productManager, + didRegisterPaymentQueueObserver: wasRegistered + ) + + updateListenerTask = nil + messageListenerTask = nil + unfinishedTransactionTask = nil + productManager = nil + return resources + } + } + + func detachTasksForDeinit() -> DeinitResources { + withLock { + let resources = DeinitResources( + initTask: initTask, + endTask: endTask, + updateListenerTask: updateListenerTask, + messageListenerTask: messageListenerTask, + unfinishedTransactionTask: unfinishedTransactionTask + ) + + initTask = nil + initTaskGeneration = nil + endTask = nil + endTaskGeneration = nil + updateListenerTask = nil + messageListenerTask = nil + unfinishedTransactionTask = nil + return resources + } + } + + // MARK: - Locking + + private func clearEndTask(generation: UInt64) { + withLock { + if endTaskGeneration == generation { + endTask = nil + endTaskGeneration = nil + } + } + } + + private func withLock(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body() + } +} diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 2766b044..452f875a 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -21,17 +21,8 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// aren't @objc-bridged automatically. @objc public class func sharedInstance() -> OpenIapModule { shared } - private var updateListenerTask: Task? - private var messageListenerTask: Task? - private var unfinishedTransactionTask: Task? - private var productManager: ProductManager? private let state = IapState() - private var initTask: Task? - private var initTaskGeneration: UInt64? - private var endTask: Task? - private var endTaskGeneration: UInt64? - private var connectionGeneration: UInt64 = 0 - private let connectionLock = NSLock() + private let connection = OpenIapConnectionLifecycle() private static let subscriptionPreflightTimeoutNanoseconds: UInt64 = 750_000_000 private enum SubscriptionPreflightOutcome { @@ -39,20 +30,6 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { case timedOut } - private typealias ConnectionCleanupResources = ( - updateListenerTask: Task?, - messageListenerTask: Task?, - unfinishedTransactionTask: Task?, - productManager: ProductManager?, - didRegisterPaymentQueueObserver: Bool - ) - - // iOS-only: SKPaymentQueue observer for promoted in-app purchases - // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases - #if os(iOS) - private var didRegisterPaymentQueueObserver = false - #endif - private override init() { super.init() } @@ -73,7 +50,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// See: https://www.openiap.dev/docs/apis/init-connection public func initConnection() async throws -> Bool { while true { - if let endTask = currentEndConnectionTask() { + if let endTask = connection.currentEndTask() { await endTask.value continue } @@ -82,20 +59,23 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return true } - guard let (task, generation) = makeInitConnectionTask() else { + guard let initWork = connection.makeInitTask(operation: { [weak self] generation in + guard let self else { return false } + return try await self.performInitConnection(generation: generation) + }) else { await Task.yield() continue } do { - let value = try await task.value - clearInitConnectionTask(generation: generation) + let value = try await initWork.task.value + connection.clearInitTask(generation: initWork.generation) return value } catch is CancellationError { - clearInitConnectionTask(generation: generation) + connection.clearInitTask(generation: initWork.generation) return false } catch { - clearInitConnectionTask(generation: generation) + connection.clearInitTask(generation: initWork.generation) throw error } } @@ -104,7 +84,10 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// Close the store connection and release resources. /// See: https://www.openiap.dev/docs/apis/end-connection public func endConnection() async throws -> Bool { - let task = makeEndConnectionTask() + let task = connection.makeEndTask { [weak self] in + guard let self else { return } + await self.cleanupExistingState() + } await task.value return true } @@ -131,7 +114,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } try await ensureConnection() - guard let productManager = currentProductManager() else { + guard let productManager = connection.currentProductManager() else { let error = makePurchaseError(code: .notPrepared) emitPurchaseError(error) throw error @@ -1409,54 +1392,20 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // MARK: - Private Helpers - private func withConnectionLock(_ body: () throws -> T) rethrows -> T { - connectionLock.lock() - defer { connectionLock.unlock() } - return try body() - } - - private func currentEndConnectionTask() -> Task? { - withConnectionLock { - endTask - } - } - - private func makeInitConnectionTask() -> (task: Task, generation: UInt64)? { - withConnectionLock { - guard endTask == nil else { - return nil - } - - if let initTask, initTaskGeneration == connectionGeneration { - return (initTask, connectionGeneration) - } - - connectionGeneration += 1 - let generation = connectionGeneration - let task = Task { [weak self] in - guard let self else { return false } - return try await self.performInitConnection(generation: generation) - } - initTask = task - initTaskGeneration = generation - return (task, generation) - } - } - private func performInitConnection(generation: UInt64) async throws -> Bool { try Task.checkCancellation() - try ensureCurrentConnectionGeneration(generation) + try connection.ensureCurrent(generation) if await state.isInitialized { return true } - _ = try getOrCreateProductManager(generation: generation) + _ = try connection.getOrCreateProductManager(generation: generation) // iOS-only: Register SKPaymentQueue observer for promoted in-app purchases // Reference: https://developer.apple.com/documentation/storekit/promoting-in-app-purchases #if os(iOS) - if try markPaymentQueueObserverRegisteredIfNeeded(generation: generation) { + if try connection.markPaymentQueueObserverRegisteredIfNeeded(generation: generation) { try Task.checkCancellation() await MainActor.run { SKPaymentQueue.default().add(self) @@ -1465,7 +1414,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { #endif // os(iOS) try Task.checkCancellation() - try ensureCurrentConnectionGeneration(generation) + try connection.ensureCurrent(generation) guard AppStore.canMakePayments else { emitPurchaseError(makePurchaseError(code: .iapNotAvailable)) @@ -1474,167 +1423,24 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } try Task.checkCancellation() - try ensureCurrentConnectionGeneration(generation) + try connection.ensureCurrent(generation) await state.setInitialized(true) try startTransactionListener(generation: generation) try startUnfinishedTransactionProcessing(generation: generation) let hasSubscriptionBillingIssueListeners = await state.hasSubscriptionBillingIssueListeners() try Task.checkCancellation() - try ensureCurrentConnectionGeneration(generation) + try connection.ensureCurrent(generation) if hasSubscriptionBillingIssueListeners { try startMessageListener(generation: generation) } try Task.checkCancellation() - try ensureCurrentConnectionGeneration(generation) + try connection.ensureCurrent(generation) return true } - private func makeEndConnectionTask() -> Task { - withConnectionLock { - if let endTask { - return endTask - } - - connectionGeneration += 1 - let generation = connectionGeneration - let taskToCancel = initTask - initTask = nil - initTaskGeneration = nil - let task = Task { [weak self] in - taskToCancel?.cancel() - if let taskToCancel { - _ = try? await taskToCancel.value - } - - guard let self else { return } - await self.cleanupExistingState() - self.clearEndConnectionTask(generation: generation) - } - endTask = task - endTaskGeneration = generation - return task - } - } - - private func clearInitConnectionTask(generation: UInt64) { - withConnectionLock { - if initTaskGeneration == generation { - initTask = nil - initTaskGeneration = nil - } - } - } - - private func clearEndConnectionTask(generation: UInt64) { - withConnectionLock { - if endTaskGeneration == generation { - endTask = nil - endTaskGeneration = nil - } - } - } - - private func clearUnfinishedTransactionTask(generation: UInt64) { - withConnectionLock { - if connectionGeneration == generation { - unfinishedTransactionTask = nil - } - } - } - - private func ensureCurrentConnectionGeneration(_ generation: UInt64) throws { - try withConnectionLock { - guard connectionGeneration == generation else { - throw CancellationError() - } - } - } - - private func getOrCreateProductManager(generation: UInt64) throws -> ProductManager { - try withConnectionLock { - guard connectionGeneration == generation else { - throw CancellationError() - } - - if productManager == nil { - productManager = ProductManager() - } - - guard let productManager else { - throw CancellationError() - } - return productManager - } - } - - private func currentProductManager() -> ProductManager? { - withConnectionLock { - productManager - } - } - - #if os(iOS) - private func markPaymentQueueObserverRegisteredIfNeeded(generation: UInt64) throws -> Bool { - try withConnectionLock { - guard connectionGeneration == generation else { - throw CancellationError() - } - - if didRegisterPaymentQueueObserver { - return false - } - - didRegisterPaymentQueueObserver = true - return true - } - } - #endif - - private func detachConnectionResourcesForCleanup() -> ConnectionCleanupResources { - withConnectionLock { - #if os(iOS) - let wasRegistered = self.didRegisterPaymentQueueObserver - self.didRegisterPaymentQueueObserver = false - #else - let wasRegistered = false - #endif - - let resources = ConnectionCleanupResources( - updateListenerTask: updateListenerTask, - messageListenerTask: messageListenerTask, - unfinishedTransactionTask: unfinishedTransactionTask, - productManager: productManager, - didRegisterPaymentQueueObserver: wasRegistered - ) - - updateListenerTask = nil - messageListenerTask = nil - unfinishedTransactionTask = nil - productManager = nil - return resources - } - } - private func cancelConnectionTasksForDeinit() { - let resources = withConnectionLock { - let resources = ( - initTask: initTask, - endTask: endTask, - updateListenerTask: updateListenerTask, - messageListenerTask: messageListenerTask, - unfinishedTransactionTask: unfinishedTransactionTask - ) - initTask = nil - initTaskGeneration = nil - endTask = nil - endTaskGeneration = nil - updateListenerTask = nil - messageListenerTask = nil - unfinishedTransactionTask = nil - return resources - } - + let resources = connection.detachTasksForDeinit() resources.initTask?.cancel() resources.endTask?.cancel() resources.updateListenerTask?.cancel() @@ -1643,7 +1449,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } private func ensureConnection() async throws { - if let endTask = currentEndConnectionTask() { + if let endTask = connection.currentEndTask() { await endTask.value } @@ -1665,7 +1471,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } private func cleanupExistingState() async { - let resources = detachConnectionResourcesForCleanup() + let resources = connection.detachResourcesForCleanup() resources.updateListenerTask?.cancel() resources.messageListenerTask?.cancel() resources.unfinishedTransactionTask?.cancel() @@ -1683,7 +1489,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } private func storeProduct(for sku: String) async throws -> StoreKit.Product { - guard let productManager = currentProductManager() else { + guard let productManager = connection.currentProductManager() else { let error = makePurchaseError(code: .notPrepared) emitPurchaseError(error) throw error @@ -1720,17 +1526,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } private func startTransactionListener(generation: UInt64) throws { - try withConnectionLock { - guard connectionGeneration == generation else { - throw CancellationError() - } - - if updateListenerTask != nil { - return - } - + try connection.startTransactionListenerTask(generation: generation) { OpenIapLog.debug("๐ŸŽง [TransactionListener] Starting Transaction.updates listener...") - updateListenerTask = Task { [weak self] in + return Task { [weak self] in guard let self else { OpenIapLog.debug("โš ๏ธ [TransactionListener] Self is nil, exiting listener") return @@ -1794,18 +1592,10 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { } private func startUnfinishedTransactionProcessing(generation: UInt64) throws { - try withConnectionLock { - guard connectionGeneration == generation else { - throw CancellationError() - } - - if unfinishedTransactionTask != nil { - return - } - - unfinishedTransactionTask = Task { [weak self] in + try connection.startUnfinishedTransactionTask(generation: generation) { + Task { [weak self] in guard let self else { return } - defer { self.clearUnfinishedTransactionTask(generation: generation) } + defer { self.connection.clearUnfinishedTransactionTask(generation: generation) } await self.processUnfinishedTransactions() } } @@ -1966,26 +1756,19 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { /// - https://developer.apple.com/documentation/storekit/message /// - https://developer.apple.com/documentation/storekit/message/reason-swift.struct/billingissue private func startMessageListener() { - try? startMessageListener(requiringGeneration: nil) + try? startMessageListener(generation: nil) } private func startMessageListener(generation: UInt64) throws { - try startMessageListener(requiringGeneration: generation) + try startMessageListener(generation: Optional(generation)) } - private func startMessageListener(requiringGeneration generation: UInt64?) throws { + private func startMessageListener(generation: UInt64?) throws { #if os(iOS) || targetEnvironment(macCatalyst) if #available(iOS 18.0, macCatalyst 18.0, *) { - try withConnectionLock { - if let generation, connectionGeneration != generation { - throw CancellationError() - } - if endTask != nil || messageListenerTask != nil { - return - } - + try connection.startMessageListenerTask(generation: generation) { OpenIapLog.debug("๐Ÿ”” [MessageListener] Starting Message.messages listener (iOS 18+)") - messageListenerTask = Task { [weak self] in + return Task { [weak self] in guard let self else { return } for await message in StoreKit.Message.messages { if Task.isCancelled { return } From 1d2a47a56805e0fcde2392310ffd31050f548237 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 07:30:00 +0900 Subject: [PATCH 7/8] refactor(apple): simplify product manager init --- .../Sources/Helpers/OpenIapConnectionLifecycle.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift b/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift index 40f79ad4..ea0d6f3b 100644 --- a/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift +++ b/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift @@ -131,13 +131,12 @@ final class OpenIapConnectionLifecycle { throw CancellationError() } - if productManager == nil { - productManager = ProductManager() + if let productManager { + return productManager } - guard let productManager else { - throw CancellationError() - } + let productManager = ProductManager() + self.productManager = productManager return productManager } } From 3e986b9c82a2bc33a709aae0fa87f84beac17e93 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 8 May 2026 07:54:26 +0900 Subject: [PATCH 8/8] fix(apple): harden connection init retry --- .../Sources/Helpers/OpenIapConnectionLifecycle.swift | 12 +++++++++++- packages/apple/Sources/OpenIapModule.swift | 10 +++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift b/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift index ea0d6f3b..c52576cc 100644 --- a/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift +++ b/packages/apple/Sources/Helpers/OpenIapConnectionLifecycle.swift @@ -82,7 +82,7 @@ final class OpenIapConnectionLifecycle { let task = Task { [weak self] in taskToCancel?.cancel() if let taskToCancel { - _ = try? await taskToCancel.value + await Self.awaitCancelledInitTask(taskToCancel) } await cleanup() @@ -272,4 +272,14 @@ final class OpenIapConnectionLifecycle { defer { lock.unlock() } return try body() } + + private static func awaitCancelledInitTask(_ task: Task) async { + do { + _ = try await task.value + } catch is CancellationError { + // Expected when endConnection cancels an in-flight initConnection. + } catch { + OpenIapLog.warn("initConnection failed while endConnection was cancelling it: \(error)") + } + } } diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 452f875a..8092dac9 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -23,6 +23,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private let state = IapState() private let connection = OpenIapConnectionLifecycle() + private static let initRetryDelayNanoseconds: UInt64 = 1_000_000 private static let subscriptionPreflightTimeoutNanoseconds: UInt64 = 750_000_000 private enum SubscriptionPreflightOutcome { @@ -63,7 +64,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { guard let self else { return false } return try await self.performInitConnection(generation: generation) }) else { - await Task.yield() + if let endTask = connection.currentEndTask() { + await endTask.value + } else { + try await Task.sleep(nanoseconds: Self.initRetryDelayNanoseconds) + } continue } @@ -1415,6 +1420,9 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { try Task.checkCancellation() try connection.ensureCurrent(generation) + if await state.isInitialized { + return true + } guard AppStore.canMakePayments else { emitPurchaseError(makePurchaseError(code: .iapNotAvailable))