Skip to content

[Android] Fix ConcurrentModificationException in GestureHandlerOrchestrator#4274

Merged
m-bert merged 5 commits into
software-mansion:mainfrom
discord:kosmydel/orchestrator-concurrency-fix
Jun 29, 2026
Merged

[Android] Fix ConcurrentModificationException in GestureHandlerOrchestrator#4274
m-bert merged 5 commits into
software-mansion:mainfrom
discord:kosmydel/orchestrator-concurrency-fix

Conversation

@kosmydel

@kosmydel kosmydel commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes a ConcurrentModificationException in GestureHandlerOrchestrator when delivering and cancelling events. Event delivery now iterates over local snapshots of gestureHandlers instead of a shared preparedHandlers field and the live asReversed() view, both of which could be mutated mid-iteration during re-entrant state updates.

Credit to @SudoPlz for the original fix. This PR upstreams it into the main repository

Note

We don't have a reliable repro for this. It surfaced in our Sentry logs as a crash, and the fix is based on inspection of the orchestrator's re-entrancy behavior.

Original patch confirmed to work

diff --git a/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt
index ced298e52339d96f871144598a5ed519cf526a89..ce87cc710eea5fa98a27dcf1f94878d38c5b40e0 100644
--- a/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt
+++ b/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt
@@ -250,19 +250,18 @@ class GestureHandlerOrchestrator(
   }

   private fun deliverEventToGestureHandlers(event: MotionEvent) {
-    // Copy handlers to "prepared handlers" array, because the list of active handlers can change
-    // as a result of state updates
-    preparedHandlers.clear()
-    preparedHandlers.addAll(gestureHandlers)
+    // Copy handlers to local array, because the list of active handlers can change
+    // as a result of state updates. Create a local snapshot to avoid race conditions.
+    val handlersToProcess = gestureHandlers.toMutableList()

     // We want to deliver events to active handlers first in order of their activation (handlers
     // that activated first will first get event delivered). Otherwise we deliver events in the
     // order in which handlers has been added ("most direct" children goes first). Therefore we rely
     // on Arrays.sort providing a stable sort (as children are registered in order in which they
     // should be tested)
-    preparedHandlers.sortWith(handlersComparator)
+    handlersToProcess.sortWith(handlersComparator)

-    for (handler in preparedHandlers) {
+    for (handler in handlersToProcess) {
       deliverEventToGestureHandler(handler, event)
     }
   }
@@ -274,11 +273,8 @@ class GestureHandlerOrchestrator(
       handler.cancel()
     }

-    // Copy handlers to "prepared handlers" array, because the list of active handlers can change
-    // as a result of state updates
-    preparedHandlers.clear()
-    preparedHandlers.addAll(gestureHandlers)
-
+    // Use reversed() directly to create a snapshot and avoid race conditions
+    // when the list of active handlers changes as a result of state updates.
     for (handler in gestureHandlers.asReversed()) {
       handler.cancel()
     }

Test steps

  1. Build and run the Android example app (apps/basic-example, yarn android).
  2. Exercise screens with multiple interacting gesture handlers (e.g. nested gestures, swipeables) and confirm no regressions in gesture behavior.

Made with Cursor

…trator

Use local snapshots of `gestureHandlers` when delivering and cancelling
events instead of a shared `preparedHandlers` field and the live
`asReversed()` view, both of which could throw during re-entrant state
updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings June 18, 2026 12:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.

@kosmydel
kosmydel marked this pull request as ready for review June 29, 2026 12:43
@kosmydel
kosmydel marked this pull request as draft June 29, 2026 12:47
@kosmydel
kosmydel marked this pull request as ready for review June 29, 2026 12:56
@m-bert
m-bert requested a review from Copilot June 29, 2026 13:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comment on lines +274 to 278
val handlersToProcess = obtainHandlerList()
handlersToProcess.addAll(gestureHandlers)

// We want to deliver events to active handlers first in order of their activation (handlers
// that activated first will first get event delivered). Otherwise we deliver events in the

@m-bert m-bert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks! ❤️

@m-bert
m-bert merged commit d2bd181 into software-mansion:main Jun 29, 2026
3 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.

3 participants