Repository navigation
Description
Steps to reproduce
InAppPurchasePlugin+StoreKit2.swift's finish(id:completion:) only invokes completion when fetchTransaction returns a non-nil transaction. When it returns nil — or when it throws — completion is never called, so the Dart Future returned by InAppPurchase.completePurchase() never completes.
This was reported and closed in #160148. The fix added completion(.success(Void())) inside the if let branch, so the nil and throw paths still hang. The code is unchanged on main today:
// packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/
// Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void) {
Task {
let transaction = try await fetchTransaction(by: UInt64(id))
if let transaction = transaction {
await transaction.finish()
completion(.success(Void()))
}
// transaction == nil -> completion is never called
// `try` throws -> error is swallowed by the Task, completion is never called
}
}
func fetchTransaction(by id: UInt64) async throws -> Transaction? {
for await result in Transaction.all {
switch result {
case .verified(let transaction):
if transaction.id == id { return transaction }
case .unverified:
continue
}
}
return nil // reachable
}fetchTransaction searches Transaction.all. Per Apple's documentation, Transaction.all excludes finished consumables, so any second completePurchase() for a consumable that was already finished lands on the nil path and hangs forever.
To reproduce:
- Use StoreKit 2 (the default since
in_app_purchase_storekit0.4.0). - Buy a consumable product and let it complete normally.
- Call
InAppPurchase.instance.completePurchase(purchase)a second time for the samepurchaseID(this happens naturally when a transaction is redelivered onpurchaseStream). - The returned
Futurenever completes and never throws.
Expected results
completePurchase() completes or throws. A transaction that cannot be found is already finished from the caller's point of view, so completion(.success(Void())) is the correct answer for the nil path.
Actual results
The Future never completes. Without a .timeout() the caller is stuck permanently; the transaction is left unfinished, is redelivered on purchaseStream on every cold start, and any further purchase attempt for the same product fails with storekit_duplicate_product_object.
Production impact
We ship a consumable-only IAP flow and wrap the call in .timeout(const Duration(seconds: 3)), recording the outcome in analytics.
Every single failure is this hang. Grouping the failures by exception over the last 14 days yields exactly one group — TimeoutException after 0:00:03.000000: Future not completed. No PlatformException, no other error type. Successful calls return in ~12 ms, so this is not slowness; the completion handler is simply never invoked.
The share of completePurchase() invocations that hang is normally 1–2%, but it is unstable — on one recent day it reached 46%.
Because a hung call leaves the transaction unfinished, it is redelivered on purchaseStream on the next cold start and the app calls completePurchase() again, forever. Counting invocations per purchaseID over 7 days:
completePurchase() calls for one purchaseID |
number of transactions |
|---|---|
| 137 | 1 |
| 47 | 4 |
| 38 / 26 / 17 / 7 / 3 / 3 | 1 each |
| exactly 2 | every other transaction |
Only 11 transactions in that window ever exceeded 2 calls, but the ones that do never converge — a single transaction was retried 137 times in 7 days and is still unfinished.
(The baseline of 2 rather than 1 appears to be a separate duplicate-delivery behaviour, cf. #183583.)
The user-visible consequence is that the affected product becomes unbuyable. In one traced case, four unfinished transactions for four different consumable products were redelivered on purchaseStream at every cold start, and tapping "buy" on any of those four produced no purchase sheet at all (storekit_duplicate_product_object), while a fifth product with no stuck transaction opened normally.
Suggested fix
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void) {
Task {
do {
let transaction = try await fetchTransaction(by: UInt64(id))
if let transaction = transaction {
await transaction.finish()
}
completion(.success(Void()))
} catch {
completion(.failure(error))
}
}
}Separately, fetchTransaction may be better served by Transaction.unfinished than Transaction.all: finish() only ever needs to act on an unfinished transaction, and Transaction.all walks the customer's entire purchase history on every call.
Code sample
Code sample
// Consumable purchase flow. `completePurchase` never completes for a
// transaction that is no longer in `Transaction.all`.
_iap.purchaseStream.listen((List<PurchaseDetails> list) {
for (final purchase in list) {
_handlePurchase(purchase);
}
});
Future<void> _handlePurchase(PurchaseDetails purchase) async {
if (purchase.status == PurchaseStatus.purchased) {
await _verifyOnServer(purchase);
}
if (purchase.pendingCompletePurchase) {
// Without the timeout this await never returns.
await _iap.completePurchase(purchase).timeout(const Duration(seconds: 3));
}
}Logs
Logs
TimeoutException after 0:00:03.000000: Future not completedFlutter Doctor output
Doctor output
[✓] Flutter (Channel stable, 3.41.4, on macOS 26.2 25C56 darwin-x64, locale ko-KR)
• Flutter version 3.41.4 on channel stable
• Framework revision ff37bef603, 2026-03-03
• Engine hash 99578ad0355da00edb26301c874a3c250a5716f5
• Dart version 3.11.1 • DevTools 2.54.1
[✓] Xcode - develop for iOS and macOS (Xcode 26.2)
[✓] Network resources
Packages:
• in_app_purchase 3.3.0
• in_app_purchase_storekit 0.4.11
• StoreKit 2 (plugin default)
Activity
saurabh-mirajkar commentedon Sep 8, 2026
@blueddarker Thanks for the detailed report. Could you please verify whether this still reproduces with Flutter 3.47.2 and the latest
in_app_purchase/in_app_purchase_storekitversions?Also, could you provide a complete minimal reproduction example (MRE) and the full output log showing the second
completePurchase()hanging?stuartmorgan-g commentedon Sep 8, 2026
This doesn't need a full repro case; the code shown still exists, and having a codepath that doesn't call the completion is a violation of the API contract.
anilcancakir commentedon Sep 27, 2026
I've sent a fix in flutter/packages#13037.
finishnow always calls its completion, socompletePurchase()returns for a transaction that is no longer inTransaction.all(such as an already finished consumable). The new regression test purchases a consumable and finishes it twice; the second call timed out before the fix.[in_app_purchase_storekit] Complete StoreKit 2 `finish` when the tran…
github-actions commentedon Oct 23, 2026
This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of
flutter doctor -vand a minimal reproduction of the issue.