Make PausingDispatcher behave like the main dispatcher when yielding
The default android main dispatcher supports yielding inside the
immediate dispatcher (see https://github.com/Kotlin/kotlinx.coroutines/commit/3ab34d808b5aa2c8673754bd43e4879b9d4af8c9).
The pausing dispatcher has not worked liked this and yield() calls are
essentially a no-op. This change fixes this and will respect yield()
calls.
Fixes: 168777346
Test: PausingDispatcherTest
Change-Id: Iffa89a264f8566e584eccc1a6ce844863cca2ad9
diff --git a/lifecycle/lifecycle-runtime-ktx/src/androidTest/java/androidx/lifecycle/Expectations.kt b/lifecycle/lifecycle-runtime-ktx/src/androidTest/java/androidx/lifecycle/Expectations.kt
index 395a840..4194da9 100644
--- a/lifecycle/lifecycle-runtime-ktx/src/androidTest/java/androidx/lifecycle/Expectations.kt
+++ b/lifecycle/lifecycle-runtime-ktx/src/androidTest/java/androidx/lifecycle/Expectations.kt
@@ -25,7 +25,7 @@
* to track execution order.
*/
class Expectations {
- private var counter = AtomicInteger(0)
+ private val counter = AtomicInteger(0)
fun expect(expected: Int) {
val order = counter.incrementAndGet()
diff --git a/lifecycle/lifecycle-runtime-ktx/src/androidTest/java/androidx/lifecycle/PausingDispatcherTest.kt b/lifecycle/lifecycle-runtime-ktx/src/androidTest/java/androidx/lifecycle/PausingDispatcherTest.kt
index 7b0d811..c9a1586 100644
--- a/lifecycle/lifecycle-runtime-ktx/src/androidTest/java/androidx/lifecycle/PausingDispatcherTest.kt
+++ b/lifecycle/lifecycle-runtime-ktx/src/androidTest/java/androidx/lifecycle/PausingDispatcherTest.kt
@@ -21,12 +21,14 @@
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.Job
+import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
@@ -47,6 +49,7 @@
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
+import kotlin.coroutines.CoroutineContext
@InternalCoroutinesApi
@SmallTest
@@ -74,13 +77,45 @@
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun updateMainHandlerAndDispatcher() {
- Dispatchers.setMain(mainExecutor.asCoroutineDispatcher())
+ Dispatchers.setMain(mainExecutor.asCoroutineDispatcher().asMain())
runBlocking(Dispatchers.Main) {
// extract the main thread to field for assertions
mainThread = Thread.currentThread()
}
}
+ /**
+ * Wraps the receiver as a [MainCoroutineDispatcher].
+ *
+ * This is needed because [PausingDispatcher] is uses [Dispatchers.Main.immediate] internally
+ * and simply calling [Dispatchers.setMain] with a coroutine dispatcher leads to a different
+ * behavior. Namely that [CoroutineDispatcher.isDispatchNeeded] always returns `true` which
+ * is not an accurate emulation of how the real android dispatcher behaves.
+ *
+ * The returned dispatcher will mimic the behavior of the default Android main dispatcher in
+ * that its immediate counterpart will return `false` from
+ * [CoroutineDispatcher.isDispatchNeeded] if the current thread is [mainThread].
+ *
+ * The receiver must be backed by a single thread for this to work properly.
+ *
+ * Ideally the real android coroutine dispatcher would be used but this will require a somewhat
+ * refactoring of these tests so for now this is a good trade-off.
+ */
+ private fun CoroutineDispatcher.asMain(immediate: Boolean = false): MainCoroutineDispatcher {
+ return object : MainCoroutineDispatcher() {
+ override val immediate: MainCoroutineDispatcher =
+ if (immediate) this else asMain(immediate = true)
+
+ override fun dispatch(context: CoroutineContext, block: Runnable) {
+ [email protected](context, block)
+ }
+
+ override fun isDispatchNeeded(context: CoroutineContext): Boolean {
+ return !immediate || Thread.currentThread() != mainThread
+ }
+ }
+ }
+
@OptIn(ExperimentalCoroutinesApi::class)
@After
fun clearHandlerAndDispatcher() {
@@ -124,8 +159,10 @@
assertThat(result).isEqualTo(3)
}
+ @OptIn(ExperimentalCoroutinesApi::class)
@Test
fun yieldTest() {
+ Dispatchers.resetMain()
runBlocking(Dispatchers.Main) {
owner.whenResumed {
expectations.expect(1)
@@ -143,6 +180,44 @@
}
}
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun yieldImmediateTest() {
+ Dispatchers.resetMain()
+ runBlocking(Dispatchers.Main.immediate) {
+ val job = owner.lifecycleScope.launchWhenResumed {
+ expectations.expect(1)
+ yield()
+ expectations.expect(3)
+ }
+ expectations.expect(2)
+ yield()
+ expectations.expect(4)
+ expectations.expectTotal(4)
+ assertThat(job.isActive).isFalse()
+ }
+ }
+
+ @Test
+ fun yieldLoop() {
+ runBlocking(Dispatchers.Main.immediate) {
+ var condition = false
+ val job = owner.lifecycleScope.launchWhenResumed {
+ expectations.expect(1)
+ while (!condition) {
+ expectations.expect(2)
+ yield()
+ expectations.expect(3)
+ }
+ }
+ expectations.expectTotal(2)
+ condition = true
+ yield()
+ assertThat(job.isActive).isFalse()
+ expectations.expectTotal(3)
+ }
+ }
+
@Test
fun runInsideMain() {
val res = runBlocking(Dispatchers.Main) {
@@ -560,4 +635,4 @@
private fun drain() {
assertThat(taskTracker.awaitIdle(10, TimeUnit.SECONDS)).isTrue()
}
-}
\ No newline at end of file
+}
diff --git a/lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/DispatchQueue.kt b/lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/DispatchQueue.kt
index 6c0568a..dc00740 100644
--- a/lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/DispatchQueue.kt
+++ b/lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/DispatchQueue.kt
@@ -22,7 +22,7 @@
import kotlinx.coroutines.Dispatchers
import java.util.ArrayDeque
import java.util.Queue
-import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.CoroutineContext
/**
* Helper class for [PausingDispatcher] that tracks runnables which are enqueued to the dispatcher
@@ -80,19 +80,24 @@
}
@MainThread
- private fun canRun() = finished || !paused
+ fun canRun() = finished || !paused
@AnyThread
@SuppressLint("WrongThread") // false negative, we are checking the thread
- fun runOrEnqueue(runnable: Runnable) {
+ fun dispatchAndEnqueue(context: CoroutineContext, runnable: Runnable) {
with(Dispatchers.Main.immediate) {
- if (isDispatchNeeded(EmptyCoroutineContext)) {
- dispatch(
- EmptyCoroutineContext,
- Runnable {
- enqueue(runnable)
- }
- )
+ // This check is here to handle a special but important case. If for example
+ // launchWhenCreated is used while not created it's expected that it will run
+ // synchronously when the lifecycle is created. If we called `dispatch` here
+ // it launches made before the required state is reached would always be deferred
+ // which is not the intended behavior.
+ //
+ // This means that calling `yield()` while paused and then receiving `resume` right
+ // after leads to the runnable being run immediately but that is indeed intended.
+ // This could be solved by implementing `dispatchYield` in the dispatcher but it's
+ // marked as internal API.
+ if (isDispatchNeeded(context) || canRun()) {
+ dispatch(context, Runnable { enqueue(runnable) })
} else {
enqueue(runnable)
}
diff --git a/lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/PausingDispatcher.kt b/lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/PausingDispatcher.kt
index 592afe7..9e56422 100644
--- a/lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/PausingDispatcher.kt
+++ b/lifecycle/lifecycle-runtime-ktx/src/main/java/androidx/lifecycle/PausingDispatcher.kt
@@ -178,7 +178,18 @@
@JvmField
internal val dispatchQueue = DispatchQueue()
- override fun dispatch(context: CoroutineContext, block: Runnable) {
- dispatchQueue.runOrEnqueue(block)
+ override fun isDispatchNeeded(context: CoroutineContext): Boolean {
+ if (Dispatchers.Main.immediate.isDispatchNeeded(context)) {
+ return true
+ }
+ // It's safe to call dispatchQueue.canRun() here because
+ // Dispatchers.Main.immediate.isDispatchNeeded returns true if we're not on the main thread
+ // If the queue is paused right now we need to dispatch so that the block is added to the
+ // the queue
+ return !dispatchQueue.canRun()
}
-}
\ No newline at end of file
+
+ override fun dispatch(context: CoroutineContext, block: Runnable) {
+ dispatchQueue.dispatchAndEnqueue(context, block)
+ }
+}