blob: e6b623d91f992547b7c66071088468e2021da94b [file] [log] [blame]
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.runtime
import androidx.compose.runtime.mock.TestMonotonicFrameClock
import androidx.compose.runtime.snapshots.Snapshot
import java.lang.ref.WeakReference
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.fail
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.Test
class RecomposerTestsJvm {
@ExperimentalCoroutinesApi
private fun runTestUnconfined(testBody: suspend TestScope.() -> Unit) {
runTest(UnconfinedTestDispatcher(), testBody = testBody)
}
@Test
@OptIn(ExperimentalComposeApi::class, ExperimentalCoroutinesApi::class)
fun concurrentRecompositionOffMainThread() = runTestUnconfined {
val dispatcher = testScheduler
withContext(dispatcher) {
val clock = TestMonotonicFrameClock(this)
withContext(clock) {
val recomposer = Recomposer(coroutineContext)
launch {
recomposer.runRecomposeConcurrentlyAndApplyChanges(Dispatchers.Default)
}
val composition = Composition(UnitApplier(), recomposer)
val threadLog = Channel<Thread>(Channel.BUFFERED)
lateinit var recomposeScope: RecomposeScope
composition.setContent {
threadLog.trySend(Thread.currentThread())
val scope = currentRecomposeScope
SideEffect {
recomposeScope = scope
}
}
val firstCompositionThread = threadLog.receive()
recomposeScope.invalidate()
dispatcher.advanceUntilIdle()
val secondCompositionThread = threadLog.receive()
assertNotEquals(firstCompositionThread, secondCompositionThread)
recomposer.close()
dispatcher.advanceUntilIdle()
}
}
}
@Test
@OptIn(ExperimentalComposeApi::class, ExperimentalCoroutinesApi::class)
fun concurrentRecompositionInvalidationDuringComposition() = runTestUnconfined {
val dispatcher = testScheduler
val clock = AutoTestFrameClock()
withContext(dispatcher + clock) {
val recomposer = Recomposer(coroutineContext)
launch {
recomposer.runRecomposeConcurrentlyAndApplyChanges(Dispatchers.Default)
}
val composition = Composition(UnitApplier(), recomposer)
var longRecomposition by mutableStateOf(false)
val longRecompositionLatch = CountDownLatch(1)
val applyCount = AtomicInteger(0)
val recomposeLatch = CountDownLatch(2)
composition.setContent {
recomposeLatch.countDown()
if (longRecomposition) {
longRecompositionLatch.await()
}
SideEffect {
applyCount.incrementAndGet()
}
}
assertEquals(1, applyCount.get(), "applyCount after initial composition")
Snapshot.withMutableSnapshot {
longRecomposition = true
}
assertTrue(recomposeLatch.await(5, TimeUnit.SECONDS), "recomposeLatch await timed out")
assertEquals(1, applyCount.get(), "applyCount after starting long recomposition")
longRecompositionLatch.countDown()
recomposer.awaitIdle()
assertEquals(2, applyCount.get(), "applyCount after long recomposition")
recomposer.close()
}
}
@Test
@OptIn(ExperimentalComposeApi::class, ObsoleteCoroutinesApi::class)
fun concurrentRecompositionOnCompositionSpecificContext() = runBlocking(AutoTestFrameClock()) {
val recomposer = Recomposer(coroutineContext)
launch {
recomposer.runRecomposeConcurrentlyAndApplyChanges(Dispatchers.Default)
}
@OptIn(DelicateCoroutinesApi::class)
newSingleThreadContext("specialThreadPool").use { pool ->
val composition = Composition(UnitApplier(), recomposer, pool)
var recomposition by mutableStateOf(false)
val recompositionThread = Channel<Thread>(1)
composition.setContent {
if (recomposition) {
recompositionThread.trySend(Thread.currentThread())
}
}
Snapshot.withMutableSnapshot {
recomposition = true
}
assertTrue(
withTimeoutOrNull(3_000) {
recompositionThread.receive()
}?.name?.contains("specialThreadPool") == true,
"recomposition did not occur on expected thread"
)
recomposer.close()
}
}
@Test
fun recomposerCancelReportsShuttingDownImmediately() = runBlocking(AutoTestFrameClock()) {
val recomposer = Recomposer(coroutineContext)
launch(start = CoroutineStart.UNDISPATCHED) {
recomposer.runRecomposeAndApplyChanges()
}
// Create a composition with a LaunchedEffect that will need to be resumed for cancellation
// before the recomposer can fully join.
Composition(UnitApplier(), recomposer).setContent {
LaunchedEffect(Unit) {
awaitCancellation()
}
}
recomposer.cancel()
// runBlocking will not dispatch resumed continuations for cancellation yet;
// read the current state immediately.
val state = recomposer.currentState.value
assertTrue(
state <= Recomposer.State.ShuttingDown,
"recomposer state $state but expected <= ShuttingDown"
)
}
@Test
fun disposedInvalidatedCompositionDoesNotLeak(): Unit = runBlocking {
val recomposer = Recomposer(coroutineContext)
// Sent to when a frame is requested by recomposer
val frameRequestCh = Channel<Unit>(Channel.CONFLATED)
// Run recompositions with a clock that will never produce a frame, thereby leaving
// invalidations unhandled. Launch undispatched to get things moving before we proceed.
launch(
BroadcastFrameClock { frameRequestCh.trySend(Unit) },
start = CoroutineStart.UNDISPATCHED
) {
recomposer.runRecomposeAndApplyChanges()
}
// Used to invalidate the composition below
var state by mutableStateOf(0)
// Create the composition to test in a function rather than directly, otherwise
// we end up with a hard reference from the stack sticking around preventing gc
fun createWeakComposition() = WeakReference(
Composition(UnitApplier(), recomposer).apply {
setContent {
// This state read will invalidate the composition
@Suppress("UNUSED_VARIABLE")
val readme = state
}
}
)
// Hold only a weak reference to this created composition for the test
val weakRef = createWeakComposition()
// Ensure the recomposer is idle and ready to receive invalidations before we commit
// a snapshot that includes one
recomposer.currentState.first { it == Recomposer.State.Idle }
// Invalidate the composition
Snapshot.withMutableSnapshot { state++ }
withTimeoutOrNull(1000) {
frameRequestCh.receive()
} ?: fail("never requested a frame from recomposer")
// Bug 209497244 tracked the Recomposer keeping this composition
// in an invalidation list after disposal; confirm below that this becomes unreachable
weakRef.get()?.dispose() ?: fail("composition prematurely collected")
Runtime.getRuntime().gc()
assertNull(weakRef.get(), "composition was not collected after disposal")
recomposer.cancel()
}
}
private class AutoTestFrameClock : MonotonicFrameClock {
private val time = AtomicLong(0)
override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R {
return onFrame(time.getAndAdd(16_000_000))
}
}