Skip to content

[in_app_purchase_storekit] StoreKit2 finish() never calls completion when the transaction is not found, so completePurchase() hangs forever (incomplete fix for #160148) #192400

Closed
flutter/packages
#13037

Description

@blueddarker

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:

  1. Use StoreKit 2 (the default since in_app_purchase_storekit 0.4.0).
  2. Buy a consumable product and let it complete normally.
  3. Call InAppPurchase.instance.completePurchase(purchase) a second time for the same purchaseID (this happens naturally when a transaction is redelivered on purchaseStream).
  4. The returned Future never 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 completed

Flutter 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

  1. saurabh-mirajkar commented on Sep 8, 2026

    @saurabh-mirajkar
    Contributor

    @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_storekit versions?

    Also, could you provide a complete minimal reproduction example (MRE) and the full output log showing the second completePurchase() hanging?

  2. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    and removed
    in triagePresently being triaged by the triage team
    on Sep 8, 2026
  3. stuartmorgan-g commented on Sep 8, 2026

    @stuartmorgan-g
    Contributor

    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.

  4. added
    platform-iosiOS applications specifically
    packageflutter/packages repository. See also p: labels.
    team-iosOwned by iOS platform team
    and removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 8, 2026
  5. added
    P1High-priority issues at the top of the work list
    triaged-iosTriaged by iOS platform team
    on Sep 10, 2026
  6. anilcancakir commented on Sep 27, 2026

    @anilcancakir

    I've sent a fix in flutter/packages#13037.

    finish now always calls its completion, so completePurchase() returns for a transaction that is no longer in Transaction.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.

  7. github-actions commented on Oct 23, 2026

    @github-actions

    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 -v and a minimal reproduction of the issue.

  8. locked as resolved and limited conversation to collaborators on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority issues at the top of the work listp: in_app_purchasePlugin for in-app purchasepackageflutter/packages repository. See also p: labels.platform-iosiOS applications specificallyteam-iosOwned by iOS platform teamtriaged-iosTriaged by iOS platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions