Merge "Fix index out of bounds crash when cancelling the Recomposer" into androidx-main
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
index e637a6a..f0e18ad 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Recomposer.kt
@@ -412,8 +412,14 @@
private fun recordComposerModificationsLocked() {
val changes = snapshotInvalidations
if (changes.isNotEmpty()) {
- knownCompositions.fastForEach { composition ->
- composition.recordModificationsOf(changes)
+ run {
+ knownCompositions.fastForEach { composition ->
+ composition.recordModificationsOf(changes)
+
+ // Avoid using knownCompositions if recording modification detected a
+ // shutdown of the recomposer.
+ if (_state.value <= State.ShuttingDown) return@run
+ }
}
snapshotInvalidations = mutableSetOf()
if (deriveStateLocked() != null) {
diff --git a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt
index 65078df..0ca1631 100644
--- a/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt
+++ b/compose/runtime/runtime/src/commonTest/kotlin/androidx/compose/runtime/RecomposerTests.kt
@@ -37,6 +37,8 @@
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.StandardTestDispatcher
@OptIn(ExperimentalCoroutinesApi::class)
class RecomposerTests {
@@ -309,6 +311,70 @@
fun constructRecomposerWithCancelledJob() {
Recomposer(Job().apply { cancel() })
}
+
+ @Test // regression test for b/243862703
+ fun cancelWithPendingInvalidations() {
+ val dispatcher = StandardTestDispatcher()
+ runTest(dispatcher) {
+ val testClock = TestMonotonicFrameClock(this)
+ withContext(testClock) {
+
+ val recomposer = Recomposer(coroutineContext)
+ var launched = false
+ val runner = launch {
+ launched = true
+ recomposer.runRecomposeAndApplyChanges()
+ }
+ val compositionOne = Composition(UnitApplier(), recomposer)
+ val compositionTwo = Composition(UnitApplier(), recomposer)
+ var state by mutableStateOf(0)
+ var lastCompositionOneState = -1
+ var lastCompositionTwoState = -1
+ compositionOne.setContent {
+ lastCompositionOneState = state
+ LaunchedEffect(Unit) {
+ delay(1_000)
+ }
+ }
+ compositionTwo.setContent {
+ lastCompositionTwoState = state
+ LaunchedEffect(Unit) {
+ delay(1_000)
+ }
+ }
+
+ assertEquals(0, lastCompositionOneState, "initial composition")
+ assertEquals(0, lastCompositionTwoState, "initial composition")
+
+ dispatcher.scheduler.runCurrent()
+
+ assertNotNull(
+ withTimeoutOrNull(3_000) { recomposer.awaitIdle() },
+ "timed out waiting for recomposer idle for recomposition"
+ )
+
+ dispatcher.scheduler.runCurrent()
+
+ assertTrue(launched, "Recomposer was never started")
+
+ Snapshot.withMutableSnapshot {
+ state = 1
+ }
+
+ recomposer.cancel()
+
+ assertNotNull(
+ withTimeoutOrNull(3_000) { recomposer.awaitIdle() },
+ "timed out waiting for recomposer idle for recomposition"
+ )
+
+ assertNotNull(
+ withTimeoutOrNull(3_000) { runner.join() },
+ "timed out waiting for recomposer runner job"
+ )
+ }
+ }
+ }
}
class UnitApplier : Applier<Unit> {