Fix #5010: serialize Purchase state on the EDT to unblock iOS main thread#5064
Merged
Conversation
…read PR #4990 introduced extra Storage I/O inside `Purchase.addPendingPurchase` (it now reads ProcessedPurchases.dat in addition to PendingPurchases.dat). That I/O happens inside `synchronized (PENDING_PURCHASE_LOCK)`, on whichever thread native code calls `postReceipt` from -- on iOS that's the main UI thread (StoreKit invokes `paymentQueue:updatedTransactions:` on it, including sandbox auto-renewals that fire on a compressed schedule on iOS 26.x). The added per-callback I/O was enough to wedge keyboard input once a TextField was active, because the same iOS main thread is responsible for both StoreKit delivery and UIKit events. Replace the three lock objects (`PENDING_PURCHASE_LOCK`, `synchronizationLock`, `receiptsLock`) with EDT-based serialization: - All state mutations (`addPendingPurchase`, `removePendingPurchase`, `recordProcessedTransactionId`, `setReceipts`, `setReceiptsRefreshTime`, `synchronizeReceipts`, `loadReceipts`, `postReceipt`) auto-dispatch to the EDT via `Display.callSerially` when called off-EDT. Once on the EDT, single-threaded execution makes `synchronized` blocks unnecessary. - `ReceiptStore.submitReceipt` / `fetchReceipts` callbacks may fire on any thread; the new `onSubmitReceiptComplete` / `onLoadReceiptsComplete` helpers (and the inline `loadReceipts` fetch callback) re-dispatch to the EDT before touching state, so reentrancy from any thread is safe. - Public read accessors (`getReceipts`, `getPendingPurchases`) preserve their existing "callable from any thread" contract by using `callSeriallyAndWait` when invoked off-EDT. - `receiptStore` is marked `volatile` for safe publication, since `setReceiptStore` / `getReceiptStore` / the `subscribe` family have no thread contract. Net effect: - iOS native thread returns from `postReceipt` immediately; Storage I/O no longer blocks the UI thread. - The recursive `synchronizeReceipts(0, null)` becomes a tail `callSerially` instead of a same-thread recursive call -- naturally bounded, no held-lock concerns. - All three lock objects, plus the `synchronized` blocks they guarded, are deleted. The only remaining `synchronized` blocks are on a local `boolean[]` monitor inside `SynchronizeReceiptsSyncRunnable` / `SynchronizeReceiptsSyncSuccessCallback`, used for the existing `invokeAndBlock` wait/notify pattern in `synchronizeReceiptsSync`. All 16 tests in `PurchaseTest` pass unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
Cloudflare Preview
|
Collaborator
Author
|
Compared 116 screenshots: 116 matched. Native Android coverage
✅ Native Android screenshot tests passed. Native Android coverage
Benchmark ResultsDetailed Performance Metrics
|
The lazy-init pattern was acceptable while the field was protected by synchronizationLock, but after the EDT-serialization refactor SpotBugs flags it as LI_LAZY_INIT_STATIC because the write to the static field is unsynchronized. Access is EDT-only by construction so there is no real race, but the simplest way to silence the analyzer is to drop the lazy init entirely: declare the list final, initialize it at class load, and remove the now-redundant null guards in synchronizeReceipts and fireSynchronizeReceiptsCallbacks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collaborator
Author
|
Compared 116 screenshots: 116 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
Collaborator
Author
|
Compared 115 screenshots: 115 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
…nnables Two PMD rules fired on the EDT-serialization refactor: - AvoidUsingVolatile on `receiptStore`. The field was not volatile before this refactor; I had added the modifier defensively for safe publication across the off-EDT setter/getter. The pre-existing behavior (plain field) was unchanged for years, so drop the modifier to stay within the project's coding standards. - MissingOverride on the nine new anonymous `Runnable` instances added by the auto-dispatch pattern. Add the @OverRide annotation to each. No behavior change. PurchaseTest 16/16 still pass; local SpotBugs and PMD both clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
✅ Continuous Quality ReportTest & Coverage
Static Analysis
Generated automatically by the PR CI workflow. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Issue #5010 reports that typing in a TextField hangs the app on iOS 26.x — but only when the user app references
com.codename1.payment.Receiptor implementsPurchaseCallback. The trigger is PR #4990, which added extra Storage I/O inside the lock-protectedaddPendingPurchasepath. On iOS,paymentQueue:updatedTransactions:invokesPurchase.postReceipton the main UI thread; sandbox auto-renewals on iOS 26 fire on a compressed schedule, redelivering transactions repeatedly. The extra I/O per redelivery is enough to wedge keyboard input, because the same iOS main thread handles both StoreKit delivery and UIKit events.Rather than just trim the per-call I/O, this PR removes the underlying main-thread-blocking pattern: all
Purchasestate mutations and Storage I/O are serialized on the CN1 EDT, with auto-dispatch from off-EDT callers. The three lock objects (PENDING_PURCHASE_LOCK,synchronizationLock,receiptsLock) and everysynchronizedblock they guarded are deleted.What changed in
Purchase.javaaddPendingPurchase,removePendingPurchase,recordProcessedTransactionId,setReceipts,setReceiptsRefreshTime,synchronizeReceipts,loadReceipts, andpostReceiptcheckDisplay.isEdt()andcallSeriallythemselves if off-EDT. Once on the EDT, single-threaded execution makes locks unnecessary.ReceiptStore.submitReceiptandfetchReceiptsmay fire on any thread. NewonSubmitReceiptComplete/onLoadReceiptsCompletehelpers (plus the inlineloadReceiptsfetch callback) re-dispatch to the EDT before touching state, so reentrancy from any background thread is safe.getReceiptsandgetPendingPurchasesusecallSeriallyAndWaitwhen called off-EDT; on-EDT they run inline.receiptStorebecomesvolatilefor safe publication, sincesetReceiptStore/getReceiptStore/ thesubscribefamily have no thread contract.synchronizeReceipts(0, null)becomes a tailcallSeriallyinstead of a same-thread recursive call — naturally bounded.The only remaining
synchronizedblocks are on a localboolean[]monitor insideSynchronizeReceiptsSyncRunnable/SynchronizeReceiptsSyncSuccessCallback, used for the existinginvokeAndBlockwait/notify pattern insynchronizeReceiptsSync— unchanged.Net effect
postReceiptimmediately. Storage I/O no longer competes with UIKit event processing.Test plan
PurchaseTestpass:mvn test -Dtest=PurchaseTest—Tests run: 16, Failures: 0, Errors: 0, Skipped: 0.Relation to prior issue #5010 work
The earlier attempt (PRs #5013, #5026, #5027 → reverted in #5041) chased a UIPress event path. The reporter later identified that removing the
Receipt/PurchaseCallbackreference resolves the symptom, pointing at the IAP code path opened by PR #4990. This PR addresses that path at the root cause.🤖 Generated with Claude Code