Skip to content

Comments

fix(android,ios): add thread safety to listener operations#3152

Merged
hyochan merged 3 commits intomainfrom
fix/listener-thread-safety
Feb 18, 2026
Merged

fix(android,ios): add thread safety to listener operations#3152
hyochan merged 3 commits intomainfrom
fix/listener-thread-safety

Conversation

@hyochan
Copy link
Owner

@hyochan hyochan commented Feb 18, 2026

Summary

  • Android: wrap listener add/remove/iterate in synchronized blocks to prevent ConcurrentModificationException
  • iOS: add warning logs when HybridRnIap is deallocated during purchase/error listener callbacks for better diagnostics

Changes

Android (HybridRnIap.kt)

  • addPurchaseUpdatedListener, removePurchaseUpdatedListener, addPurchaseErrorListener, removePurchaseErrorListener now use synchronized blocks
  • sendPurchaseUpdate and sendPurchaseError iterate listeners within synchronized blocks
  • Prevents race conditions when listeners are modified from one thread while being iterated on another

iOS (HybridRnIap.swift)

Context

Investigating #3150 (purchases not being notified) revealed a potential thread safety issue in listener management on Android, and a diagnostics gap on iOS where purchase events could be silently dropped if the native module was deallocated.

Test plan

  • yarn typecheck passes
  • yarn lint passes
  • Pre-commit hooks pass

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved thread-safety for in-app purchase listener registration and event dispatch on Android to prevent race conditions.
    • Added guards and clearer logging for in-app purchase event handling on iOS so events are dropped with warnings if the handler is no longer available.
  • Chores

    • Increased CI/CD deployment validation timeout to allow longer-running checks.

- Android: wrap listener add/remove/iterate in synchronized blocks
  to prevent ConcurrentModificationException
- iOS: add warning logs when HybridRnIap is deallocated during
  purchase/error listener callbacks for better diagnostics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @hyochan, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the stability and diagnostic capabilities of the in-app purchase (IAP) module across Android and iOS. It addresses potential concurrency issues on Android by ensuring listener operations are thread-safe, and on iOS, it provides better visibility into cases where purchase events might be silently missed due to the native module's lifecycle, ultimately leading to a more robust IAP experience.

Highlights

  • Android Thread Safety: Implemented thread safety for listener addition, removal, and iteration operations in HybridRnIap.kt using synchronized blocks to prevent ConcurrentModificationException.
  • iOS Diagnostic Logging: Added warning logs in HybridRnIap.swift to detect and report when the HybridRnIap instance is deallocated during purchase or error listener callbacks, improving diagnostics for dropped events.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
    • Wrapped addPurchaseUpdatedListener and addPurchaseErrorListener methods in synchronized blocks.
    • Wrapped removePurchaseUpdatedListener and removePurchaseErrorListener methods in synchronized blocks.
    • Wrapped the iteration logic within sendPurchaseUpdate and sendPurchaseError methods in synchronized blocks.
  • ios/HybridRnIap.swift
    • Added a RnIapLog.warn message within the purchaseUpdatedListener callback if self is deallocated.
    • Added a RnIapLog.warn message within the purchaseErrorListener callback if self is deallocated.
Activity
  • The author confirmed that yarn typecheck and yarn lint passed.
  • Pre-commit hooks were successfully executed.
  • The pull request was generated with Claude Code.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively addresses thread safety in the Android listener management by introducing synchronized blocks, which should prevent ConcurrentModificationException. The diagnostic logging added to the iOS implementation is also a valuable improvement for debugging deallocated listener issues.

However, I've identified a significant issue in the Android listener removal logic. The removePurchaseUpdatedListener and removePurchaseErrorListener methods incorrectly clear all listeners instead of just the specified one. I've left detailed comments with suggestions to fix this to ensure correct behavior and consistency with other listener removal methods in the class.

// Note: Kotlin doesn't have easy closure comparison, so we'll clear all listeners
purchaseUpdatedListeners.clear()
synchronized(purchaseUpdatedListeners) {
purchaseUpdatedListeners.clear()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using purchaseUpdatedListeners.clear() here will remove all registered listeners, not just the one provided. This can lead to subtle bugs where one part of the application inadvertently removes listeners from another part.

This is inconsistent with other listener removal methods in this class, such as removePromotedProductListenerIOS and removeUserChoiceBillingListenerAndroid, which correctly use remove(listener).

To fix this and ensure only the specific listener is removed, please change clear() to remove(listener).

Suggested change
purchaseUpdatedListeners.clear()
purchaseUpdatedListeners.remove(listener)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a7ae01d. Changed clear() to remove(listener) for targeted removal.

// Note: Kotlin doesn't have easy closure comparison, so we'll clear all listeners
purchaseErrorListeners.clear()
synchronized(purchaseErrorListeners) {
purchaseErrorListeners.clear()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to removePurchaseUpdatedListener, this method uses purchaseErrorListeners.clear(), which will remove all error listeners. This can cause other parts of the app to miss purchase error events.

Please change this to purchaseErrorListeners.remove(listener) to ensure only the specified listener is removed.

Suggested change
purchaseErrorListeners.clear()
purchaseErrorListeners.remove(listener)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a7ae01d. Changed to remove(listener) to match the targeted removal pattern.

@hyochan hyochan added 🛠 bugfix All kinds of bug fixes labels Feb 18, 2026
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 18, 2026

📝 Walkthrough

Walkthrough

Adds thread-safety around Android listener management, adds explicit deallocation guards with warning logs for iOS listener closures, and increases the CI timeout for the iOS validation job from 30 to 60 minutes.

Changes

Cohort / File(s) Summary
CI Configuration
.github/workflows/deploy.yml
Increased timeout-minutes for the validate-ios job from 30 to 60.
Android Listener Synchronization
android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
Added synchronization around mutations and iterations of purchaseUpdatedListeners and purchaseErrorListeners; use synchronized snapshots for dispatch; synchronized cleanup in endConnection/initConnection.
iOS Deallocation Handling
ios/HybridRnIap.swift
Added deallocation guards in listener closures (purchaseUpdatedListener, purchaseErrorListener, promotedProductListenerIOS) to log a warning and drop events when self has been deallocated.

Sequence Diagram(s)

(omitted)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

📱 iOS

Poem

🐰
I hopped through threads with careful paws,
Locked the lists to follow laws,
When swift friends fade, I call their name,
A warning logged, no phantom blame.
Hooray — safe hops across the game! 🎩✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the main changes across both Android and iOS platforms—adding thread safety mechanisms to listener operations. It is concise, specific, and clearly summarizes the primary objective of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/listener-thread-safety

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt (1)

782-797: Invoking callbacks inside the synchronized block risks a ConcurrentModificationException on same-thread reentrance.

Java synchronized is reentrant per thread. If a listener callback synchronously calls removePurchaseUpdatedListener (which calls purchaseUpdatedListeners.clear()), the clear happens while ArrayList.forEach is still iterating the same list, throwing a ConcurrentModificationException. The safe idiom is to snapshot the list under the lock and iterate outside it:

♻️ Proposed snapshot-before-iterate pattern
 private fun sendPurchaseUpdate(purchase: NitroPurchase) {
     RnIapLog.result(
         "sendPurchaseUpdate",
         mapOf("productId" to purchase.productId, "platform" to purchase.platform)
     )
-    synchronized(purchaseUpdatedListeners) {
-        purchaseUpdatedListeners.forEach { it(purchase) }
-    }
+    val snapshot = synchronized(purchaseUpdatedListeners) { ArrayList(purchaseUpdatedListeners) }
+    snapshot.forEach { it(purchase) }
 }

 private fun sendPurchaseError(error: NitroPurchaseResult) {
     RnIapLog.result(
         "sendPurchaseError",
         mapOf("code" to error.code, "message" to error.message)
     )
-    synchronized(purchaseErrorListeners) {
-        purchaseErrorListeners.forEach { it(error) }
-    }
+    val snapshot = synchronized(purchaseErrorListeners) { ArrayList(purchaseErrorListeners) }
+    snapshot.forEach { it(error) }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt` around lines 782
- 797, The synchronized blocks in the listener dispatch methods (e.g., where
purchaseUpdatedListeners and purchaseErrorListeners are iterated in
sendPurchaseUpdated/sendPurchaseError) call callbacks while holding the lock,
risking ConcurrentModificationException if a callback removes listeners (e.g.,
removePurchaseUpdatedListener clears the list). Fix by copying/snapshotting the
listener list inside the synchronized block (e.g., create a new List from
purchaseUpdatedListeners or purchaseErrorListeners) and then iterate over that
snapshot outside the synchronized block so callbacks run without holding the
lock.
ios/HybridRnIap.swift (1)

923-926: Consider adding a warning log to the promotedProductSub guard for consistency.

The purchaseUpdatedSub and purchaseErrorSub closures now log a warning on deallocation (lines 903 and 924), but the promotedProductSub guard at line 944 silently drops the event:

guard let self else { return }

Since this PR's intent is to improve diagnostics for dropped events (issue #3150), applying the same pattern here would make deallocation-related drops uniformly observable.

♻️ Proposed consistency fix for `promotedProductSub`
- guard let self else { return }
+ guard let self else {
+     RnIapLog.warn("promotedProductListenerIOS: HybridRnIap deallocated, promoted product event dropped")
+     return
+ }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/HybridRnIap.swift` around lines 923 - 926, The promotedProductSub closure
currently uses a silent guard (guard let self else { return }) which drops
events without a diagnostic; update the guard in the promotedProductSub closure
to mirror purchaseUpdatedSub and purchaseErrorSub by logging a warning before
returning (e.g., RnIapLog.warn("promotedProductSub: HybridRnIap deallocated,
event dropped")), ensuring the deallocation case in promotedProductSub is
observable; locate the promotedProductSub closure and replace the silent guard
with the guarded self plus RnIapLog.warn call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt`:
- Around line 734-755: endConnection() currently calls
purchaseUpdatedListeners.clear() and purchaseErrorListeners.clear() without
holding the same locks used during dispatch, which can race with
sendPurchaseUpdate/sendPurchaseError; fix by wrapping both clear() calls inside
synchronized(purchaseUpdatedListeners) and synchronized(purchaseErrorListeners)
respectively (i.e., acquire the same monitors used by add/remove and by
sendPurchaseUpdate/sendPurchaseError) so teardown cannot clear the lists while
they are being iterated.

---

Nitpick comments:
In `@android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt`:
- Around line 782-797: The synchronized blocks in the listener dispatch methods
(e.g., where purchaseUpdatedListeners and purchaseErrorListeners are iterated in
sendPurchaseUpdated/sendPurchaseError) call callbacks while holding the lock,
risking ConcurrentModificationException if a callback removes listeners (e.g.,
removePurchaseUpdatedListener clears the list). Fix by copying/snapshotting the
listener list inside the synchronized block (e.g., create a new List from
purchaseUpdatedListeners or purchaseErrorListeners) and then iterate over that
snapshot outside the synchronized block so callbacks run without holding the
lock.

In `@ios/HybridRnIap.swift`:
- Around line 923-926: The promotedProductSub closure currently uses a silent
guard (guard let self else { return }) which drops events without a diagnostic;
update the guard in the promotedProductSub closure to mirror purchaseUpdatedSub
and purchaseErrorSub by logging a warning before returning (e.g.,
RnIapLog.warn("promotedProductSub: HybridRnIap deallocated, event dropped")),
ensuring the deallocation case in promotedProductSub is observable; locate the
promotedProductSub closure and replace the silent guard with the guarded self
plus RnIapLog.warn call.

Android:
- Use remove(listener) instead of clear() for targeted removal
- Use snapshot-before-iterate pattern to prevent reentrance issues
- Add synchronized blocks to endConnection() clear() calls

iOS:
- Add warning log to promotedProductSub guard for consistency
Copy link
Owner Author

@hyochan hyochan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleted

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ios/HybridRnIap.swift (1)

876-894: iOS listener arrays need synchronization protection against concurrent mutations and iterations.

purchaseUpdatedListeners, purchaseErrorListeners, and promotedProductListeners are mutated by add/remove methods (which run on the JS runtime thread per Nitro's dispatcher model) and iterated inside MainActor closures, creating a race condition when mutations and iterations overlap. Android implements this correctly with synchronized blocks and snapshot-based iteration; iOS lacks this protection. Since the PR scopes iOS to diagnostics only, this is appropriate to address in a follow-up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/HybridRnIap.swift` around lines 876 - 894, The listener arrays
purchaseUpdatedListeners, purchaseErrorListeners, and promotedProductListeners
are not thread-safe: addPurchaseUpdatedListener / addPurchaseErrorListener and
the removePurchase... methods mutate them on the JS runtime thread while they
are iterated inside MainActor closures, causing races; fix by introducing a
single dedicated synchronization primitive (e.g., a private serial DispatchQueue
or NSLock or a Swift actor) guarding all accesses to these arrays, update
addPurchaseUpdatedListener, addPurchaseErrorListener,
removePurchaseUpdatedListener, removePurchaseErrorListener to perform mutations
on that lock/queue, and change any iteration sites to take a snapshot under the
same lock (copy the array while holding the lock, then iterate the copy on
MainActor) so iterations do not race with concurrent mutations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ios/HybridRnIap.swift`:
- Around line 876-894: The listener arrays purchaseUpdatedListeners,
purchaseErrorListeners, and promotedProductListeners are not thread-safe:
addPurchaseUpdatedListener / addPurchaseErrorListener and the removePurchase...
methods mutate them on the JS runtime thread while they are iterated inside
MainActor closures, causing races; fix by introducing a single dedicated
synchronization primitive (e.g., a private serial DispatchQueue or NSLock or a
Swift actor) guarding all accesses to these arrays, update
addPurchaseUpdatedListener, addPurchaseErrorListener,
removePurchaseUpdatedListener, removePurchaseErrorListener to perform mutations
on that lock/queue, and change any iteration sites to take a snapshot under the
same lock (copy the array while holding the lock, then iterate the copy on
MainActor) so iterations do not race with concurrent mutations.

@hyochan hyochan merged commit e130706 into main Feb 18, 2026
4 checks passed
@hyochan hyochan deleted the fix/listener-thread-safety branch February 18, 2026 16:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🛠 bugfix All kinds of bug fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant