Skip to content

Fix #5010: serialize Purchase state on the EDT to unblock iOS main thread#5064

Merged
shai-almog merged 3 commits into
masterfrom
fix-5010-purchase-edt-serialization
May 28, 2026
Merged

Fix #5010: serialize Purchase state on the EDT to unblock iOS main thread#5064
shai-almog merged 3 commits into
masterfrom
fix-5010-purchase-edt-serialization

Conversation

@shai-almog
Copy link
Copy Markdown
Collaborator

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.Receipt or implements PurchaseCallback. The trigger is PR #4990, which added extra Storage I/O inside the lock-protected addPendingPurchase path. On iOS, paymentQueue:updatedTransactions: invokes Purchase.postReceipt on 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 Purchase state 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 every synchronized block they guarded are deleted.

What changed in Purchase.java

  • Mutators auto-dispatch to the EDT. addPendingPurchase, removePendingPurchase, recordProcessedTransactionId, setReceipts, setReceiptsRefreshTime, synchronizeReceipts, loadReceipts, and postReceipt check Display.isEdt() and callSerially themselves if off-EDT. Once on the EDT, single-threaded execution makes locks unnecessary.
  • Receipt-store callbacks re-dispatch. ReceiptStore.submitReceipt and fetchReceipts may fire on any thread. New onSubmitReceiptComplete / onLoadReceiptsComplete helpers (plus the inline loadReceipts fetch callback) re-dispatch to the EDT before touching state, so reentrancy from any background thread is safe.
  • Public readers preserve any-thread contract. getReceipts and getPendingPurchases use callSeriallyAndWait when called off-EDT; on-EDT they run inline.
  • receiptStore becomes volatile for safe publication, since setReceiptStore / getReceiptStore / the subscribe family have no thread contract.
  • The recursive synchronizeReceipts(0, null) becomes a tail callSerially instead of a same-thread recursive call — naturally bounded.

The only remaining synchronized blocks are on a local boolean[] monitor inside SynchronizeReceiptsSyncRunnable / SynchronizeReceiptsSyncSuccessCallback, used for the existing invokeAndBlock wait/notify pattern in synchronizeReceiptsSync — unchanged.

Net effect

  • iOS main thread returns from postReceipt immediately. Storage I/O no longer competes with UIKit event processing.
  • No lock-ordering questions; the EDT is the single serialization point.
  • No new threads, no new dependencies.

Test plan

  • All 16 existing tests in PurchaseTest pass: mvn test -Dtest=PurchaseTestTests run: 16, Failures: 0, Errors: 0, Skipped: 0.
  • iOS verification by issue reporter (@Ngosti2000) once a build is available — the deadlock symptom requires iOS 26.x StoreKit redelivery, which the local simulator doesn't reproduce.

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 / PurchaseCallback reference 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

…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>
@github-actions
Copy link
Copy Markdown
Contributor

Cloudflare Preview

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 28, 2026

Compared 116 screenshots: 116 matched.

Native Android coverage

  • 📊 Line coverage: 12.46% (7225/57972 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 10.16% (36306/357360), branch 4.28% (1447/33836), complexity 5.31% (1726/32486), method 9.26% (1409/15210), class 15.21% (322/2117)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

✅ Native Android screenshot tests passed.

Native Android coverage

  • 📊 Line coverage: 12.46% (7225/57972 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 10.16% (36306/357360), branch 4.28% (1447/33836), complexity 5.31% (1726/32486), method 9.26% (1409/15210), class 15.21% (322/2117)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

Benchmark Results

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 682.000 ms
Base64 CN1 encode 299.000 ms
Base64 encode ratio (CN1/native) 0.438x (56.2% faster)
Base64 native decode 1001.000 ms
Base64 CN1 decode 265.000 ms
Base64 decode ratio (CN1/native) 0.265x (73.5% faster)
Image encode benchmark status skipped (SIMD unsupported)

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>
@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 28, 2026

Compared 116 screenshots: 116 matched.
✅ Native iOS Metal screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 441 seconds

Build and Run Timing

Metric Duration
Simulator Boot 96000 ms
Simulator Boot (Run) 1000 ms
App Install 33000 ms
App Launch 19000 ms
Test Execution 324000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 576.000 ms
Base64 CN1 encode 1315.000 ms
Base64 encode ratio (CN1/native) 2.283x (128.3% slower)
Base64 native decode 291.000 ms
Base64 CN1 decode 1075.000 ms
Base64 decode ratio (CN1/native) 3.694x (269.4% slower)
Base64 SIMD encode 466.000 ms
Base64 encode ratio (SIMD/native) 0.809x (19.1% faster)
Base64 encode ratio (SIMD/CN1) 0.354x (64.6% faster)
Base64 SIMD decode 461.000 ms
Base64 decode ratio (SIMD/native) 1.584x (58.4% slower)
Base64 decode ratio (SIMD/CN1) 0.429x (57.1% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 103.000 ms
Image createMask (SIMD on) 11.000 ms
Image createMask ratio (SIMD on/off) 0.107x (89.3% faster)
Image applyMask (SIMD off) 136.000 ms
Image applyMask (SIMD on) 60.000 ms
Image applyMask ratio (SIMD on/off) 0.441x (55.9% faster)
Image modifyAlpha (SIMD off) 126.000 ms
Image modifyAlpha (SIMD on) 55.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.437x (56.3% faster)
Image modifyAlpha removeColor (SIMD off) 349.000 ms
Image modifyAlpha removeColor (SIMD on) 89.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.255x (74.5% faster)
Image PNG encode (SIMD off) 1291.000 ms
Image PNG encode (SIMD on) 838.000 ms
Image PNG encode ratio (SIMD on/off) 0.649x (35.1% faster)
Image JPEG encode 440.000 ms

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 28, 2026

Compared 115 screenshots: 115 matched.
✅ Native iOS screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 337 seconds

Build and Run Timing

Metric Duration
Simulator Boot 102000 ms
Simulator Boot (Run) 1000 ms
App Install 19000 ms
App Launch 11000 ms
Test Execution 386000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 1068.000 ms
Base64 CN1 encode 2960.000 ms
Base64 encode ratio (CN1/native) 2.772x (177.2% slower)
Base64 native decode 541.000 ms
Base64 CN1 decode 1765.000 ms
Base64 decode ratio (CN1/native) 3.262x (226.2% slower)
Base64 SIMD encode 540.000 ms
Base64 encode ratio (SIMD/native) 0.506x (49.4% faster)
Base64 encode ratio (SIMD/CN1) 0.182x (81.8% faster)
Base64 SIMD decode 642.000 ms
Base64 decode ratio (SIMD/native) 1.187x (18.7% slower)
Base64 decode ratio (SIMD/CN1) 0.364x (63.6% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 277.000 ms
Image createMask (SIMD on) 44.000 ms
Image createMask ratio (SIMD on/off) 0.159x (84.1% faster)
Image applyMask (SIMD off) 452.000 ms
Image applyMask (SIMD on) 156.000 ms
Image applyMask ratio (SIMD on/off) 0.345x (65.5% faster)
Image modifyAlpha (SIMD off) 274.000 ms
Image modifyAlpha (SIMD on) 226.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.825x (17.5% faster)
Image modifyAlpha removeColor (SIMD off) 339.000 ms
Image modifyAlpha removeColor (SIMD on) 138.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.407x (59.3% faster)
Image PNG encode (SIMD off) 1755.000 ms
Image PNG encode (SIMD on) 1337.000 ms
Image PNG encode ratio (SIMD on/off) 0.762x (23.8% faster)
Image JPEG encode 732.000 ms

…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>
@github-actions
Copy link
Copy Markdown
Contributor

✅ Continuous Quality Report

Test & Coverage

Static Analysis

  • SpotBugs [Report archive]
    • ByteCodeTranslator: 0 findings (no issues)
    • android: 0 findings (no issues)
    • codenameone-maven-plugin: 0 findings (no issues)
    • core-unittests: 0 findings (no issues)
    • ios: 0 findings (no issues)
  • PMD: 0 findings (no issues) [Report archive]
  • Checkstyle: 0 findings (no issues) [Report archive]

Generated automatically by the PR CI workflow.

@shai-almog shai-almog merged commit 96983a4 into master May 28, 2026
21 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant