Skip to content

Commit

Permalink
fix: prevent concurrency issues in in-app listeners (#246)
Browse files Browse the repository at this point in the history
  • Loading branch information
mrehan27 committed Aug 23, 2023
1 parent 0587efa commit 72dafd7
Show file tree
Hide file tree
Showing 2 changed files with 154 additions and 1 deletion.
Expand Up @@ -12,6 +12,7 @@ import io.customer.messaginginapp.gist.data.listeners.Queue
import io.customer.messaginginapp.gist.data.model.GistMessageProperties
import io.customer.messaginginapp.gist.data.model.Message
import io.customer.messaginginapp.gist.data.model.MessagePosition
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
Expand All @@ -35,7 +36,7 @@ object GistSdk : Application.ActivityLifecycleCallbacks {
internal lateinit var gistEnvironment: GistEnvironment
internal lateinit var application: Application

private val listeners: MutableList<GistListener> = mutableListOf()
private val listeners: CopyOnWriteArrayList<GistListener> = CopyOnWriteArrayList()

private var resumedActivities = mutableSetOf<String>()

Expand Down
@@ -0,0 +1,152 @@
package io.customer.messaginginapp

import androidx.test.ext.junit.runners.AndroidJUnit4
import io.customer.commontest.BaseTest
import io.customer.messaginginapp.gist.data.model.Message
import io.customer.messaginginapp.gist.presentation.GistListener
import io.customer.messaginginapp.gist.presentation.GistSdk
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import org.amshove.kluent.internal.assertEquals
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
internal class GistSdkListenersTest : BaseTest() {
/**
* This test validates if individual listeners can be removed without any exceptions.
* See https://github.com/customerio/customerio-android/issues/245 for more details.
*
* The test run following steps to validate the functionality:
* - Creates 100 listeners and adds them to the SDK.
* - Starts a thread to remove the listeners one by one.
* - Starts another thread in parallel to emit an error to the SDK 20 times.
* - Ensure both threads run in parallel and completed without any exceptions within the timeout.
*/
@Test
fun processAndRemoveListenersIndividually_givenConcurrentModification_expectSuccessfulCompletion() {
val listenersCount = 100
val emitEventsCount = listenersCount / 5
val listeners = ArrayList<GistListener>(listenersCount)
val threadsCompletionLatch = CountDownLatch(2)

// Add listeners
repeat(listenersCount) {
listeners.add(emptyGistListener())
GistSdk.addListener(listeners.last())
}

// Create a thread to remove listeners one by one
val removeListenersThread = thread(start = false) {
repeat(listenersCount) { index ->
GistSdk.removeListener(listeners[index])
}
threadsCompletionLatch.countDown()
}

// Create a thread to emit events
val handleGistErrorThread = thread(start = false) {
repeat(emitEventsCount) {
GistSdk.handleGistError(Message())
}
threadsCompletionLatch.countDown()
}

// Start both threads in parallel
handleGistErrorThread.start()
removeListenersThread.start()

// Wait for threads to complete without any exceptions within the timeout
// If there is any exception, the latch will not be decremented
threadsCompletionLatch.await(10, TimeUnit.SECONDS)

// Assert that threads completed without any exceptions within the timeout
assertEquals(
expected = 0L,
actual = threadsCompletionLatch.count,
message = "Threads did not complete within the timeout"
)
}

/**
* This test validates if all listeners can be removed together without any exceptions.
* See https://github.com/customerio/customerio-android/issues/245 for more details.
*
* The test run following steps to validate the functionality:
* - Creates 100 listeners and adds them to the SDK.
* - Starts a thread that sleeps for 1 second, then clears all listeners at once.
* - Starts another thread in parallel to emit an error to the SDK 20 times.
* - Ensure both threads run in parallel and completed without any exceptions within the timeout.
*/
@Test
fun processAndClearListenersAllAtOnce_givenConcurrentModification_expectSuccessfulCompletion() {
val listenersCount = 100
val emitEventsCount = listenersCount / 5
val listeners = ArrayList<GistListener>(listenersCount)
val threadsCompletionLatch = CountDownLatch(2)

// Add listeners
repeat(listenersCount) {
listeners.add(emptyGistListener())
GistSdk.addListener(listeners.last())
}

// Create a thread to remove all listeners
val removeListenersThread = thread(start = false) {
// Sleep for 1 second to ensure that the other thread has started
// and is in the process of emitting events
Thread.sleep(1000)
GistSdk.clearListeners()
threadsCompletionLatch.countDown()
}

// Create a thread to emit events
val handleGistErrorThread = thread(start = false) {
repeat(emitEventsCount) {
GistSdk.handleGistError(Message())
// Sleep for 100ms to ensure that the other thread gets
// enough time to remove listeners
Thread.sleep(100)
}
threadsCompletionLatch.countDown()
}

// Start both threads in parallel
handleGistErrorThread.start()
removeListenersThread.start()

// Wait for threads to complete without any exceptions within the timeout
// If there is any exception, the latch will not be decremented
threadsCompletionLatch.await(10, TimeUnit.SECONDS)

// Assert that threads completed without any exceptions within the timeout
assertEquals(
expected = 0L,
actual = threadsCompletionLatch.count,
message = "Threads did not complete within the timeout"
)
}

private fun emptyGistListener() = object : GistListener {
override fun embedMessage(message: Message, elementId: String) {
}

override fun onMessageShown(message: Message) {
}

override fun onMessageDismissed(message: Message) {
}

override fun onError(message: Message) {
}

override fun onAction(
message: Message,
currentRoute: String,
action: String,
name: String
) {
}
}
}

0 comments on commit 72dafd7

Please sign in to comment.