Composition error handling for hot reload

Catches errors in composition when hot reload is enabled. The errors are separated into three groups:
1) Internal runtime errors. They are ignored and app crashes as usual.
2) Recoverable composition errors. The error is recorded and recomposer is suspended (by moving it into `Idle` state). Composition loop is restarted once hot reload executes refresh. Currently only errors happening during compose are recoverable.
3) Unrecoverable composition errors. These errors are recorded similarly to recoverable ones, but the error is only cleared after `loadStateAndComposeForHotReload`. Errors in remember observers / side effects / applier logic are currently marked as "unrecoverable".

Current errors are exposed through `HotReloader.getCurrentErrors` method, listing `cause` and whether the error is `recoverable`.

Fixes: 225400205
Test: LiveEditTests, LiveEditApiTests

Change-Id: Ia0b7cb78c80b9f677a750112d2108c8f24f98a17
diff --git a/compose/runtime/runtime/integration-tests/src/androidAndroidTest/kotlin/androidx/compose/runtime/LiveEditApiTests.kt b/compose/runtime/runtime/integration-tests/src/androidAndroidTest/kotlin/androidx/compose/runtime/LiveEditApiTests.kt
index 6d17f5f..3e04f55 100644
--- a/compose/runtime/runtime/integration-tests/src/androidAndroidTest/kotlin/androidx/compose/runtime/LiveEditApiTests.kt
+++ b/compose/runtime/runtime/integration-tests/src/androidAndroidTest/kotlin/androidx/compose/runtime/LiveEditApiTests.kt
@@ -19,13 +19,17 @@
 import androidx.compose.material.Text
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
 import org.junit.Assert.assertTrue
+import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 
 var someData = mutableStateOf(0)
 
+@OptIn(InternalComposeApi::class)
 @RunWith(AndroidJUnit4::class)
 class LiveEditApiTests : BaseComposeTest() {
     @get:Rule
@@ -35,6 +39,20 @@
         invalidateGroupsWithKey(key)
     }
 
+    private fun compositionErrors(): List<Pair<Exception, Boolean>> =
+        currentCompositionErrors()
+
+    @Before
+    fun setUp() {
+        // ensures recomposer knows that hot reload is on
+        invalidateGroupsWithKey(-1)
+    }
+
+    @After
+    fun tearDown() {
+        clearCompositionErrors()
+    }
+
     // IMPORTANT: This must be the first test as the lambda key will change if the lambda is
     // moved this file.
     @Test
@@ -169,6 +187,211 @@
             )
         }
     }
+
+    @Test
+    @MediumTest
+    fun throwError_doesntCrash() {
+        activity.show {
+            TestError()
+        }
+
+        activity.waitForAFrame()
+
+        // Invalidate error scope
+        run {
+            val errorStart = errorInvoked
+            invalidateGroup(errorKey)
+
+            assertTrue(
+                "TestError should have been invoked",
+                errorInvoked > errorStart
+            )
+        }
+    }
+
+    @Test
+    @MediumTest
+    fun throwError_invalidatesOnlyAfterHotReloadCall() {
+        val shouldThrow = mutableStateOf(true)
+
+        activity.show {
+            TestError { shouldThrow.value }
+        }
+
+        activity.waitForAFrame()
+
+        run {
+            val errorStart = errorInvoked
+            shouldThrow.value = false
+
+            activity.waitForAFrame()
+
+            assertTrue(
+                "TestError should not have been invoked",
+                errorInvoked == errorStart
+            )
+
+            invalidateGroup(errorKey)
+            assertTrue(
+                "TestError should have been invoked",
+                errorInvoked > errorStart
+            )
+        }
+    }
+
+    @Test
+    @MediumTest
+    fun throwError_recompose_doesntCrash() {
+        val shouldThrow = mutableStateOf(false)
+        activity.show {
+            TestError { shouldThrow.value }
+        }
+
+        activity.waitForAFrame()
+
+        run {
+            var errors = compositionErrors()
+            assertThat(errors).isEmpty()
+
+            shouldThrow.value = true
+            activity.waitForAFrame()
+
+            errors = compositionErrors()
+            assertThat(errors).hasSize(1)
+            assertThat(errors[0].first.message).isEqualTo("Test crash!")
+            assertThat(errors[0].second).isEqualTo(true)
+        }
+    }
+
+    @Test
+    @MediumTest
+    fun throwError_recompose_clearErrorOnInvalidate() {
+        var shouldThrow by mutableStateOf(false)
+        activity.show {
+            TestError { shouldThrow }
+        }
+
+        activity.waitForAFrame()
+
+        run {
+            var errors = compositionErrors()
+            assertThat(errors).isEmpty()
+
+            shouldThrow = true
+            activity.waitForAFrame()
+
+            errors = compositionErrors()
+            assertThat(errors).hasSize(1)
+
+            shouldThrow = false
+            invalidateGroupsWithKey(errorKey)
+
+            errors = compositionErrors()
+            assertThat(errors).isEmpty()
+        }
+    }
+
+    @Test
+    @MediumTest
+    fun throwError_returnsCurrentError() {
+        var shouldThrow by mutableStateOf(true)
+        activity.show {
+            TestError { shouldThrow }
+        }
+
+        activity.waitForAFrame()
+
+        run {
+            var errors = compositionErrors()
+            assertThat(errors).hasSize(1)
+            assertThat(errors[0].first.message).isEqualTo("Test crash!")
+            assertThat(errors[0].second).isEqualTo(true)
+
+            shouldThrow = false
+            invalidateGroup(errorKey)
+
+            errors = compositionErrors()
+            assertThat(errors).isEmpty()
+        }
+    }
+
+    @Test
+    @MediumTest
+    fun throwErrorInEffect_doesntCrash() {
+        activity.show {
+            TestEffectError()
+        }
+
+        activity.waitForAFrame()
+
+        run {
+            var errors = compositionErrors()
+            assertThat(errors).hasSize(1)
+            assertThat(errors[0].first.message).isEqualTo("Effect error!")
+            assertThat(errors[0].second).isEqualTo(false)
+
+            val start = effectErrorInvoked
+            simulateHotReload(Unit)
+
+            assertTrue("TestEffectError should be invoked!", effectErrorInvoked > start)
+
+            errors = compositionErrors()
+            assertThat(errors).hasSize(1)
+            assertThat(errors[0].first.message).isEqualTo("Effect error!")
+            assertThat(errors[0].second).isEqualTo(false)
+        }
+    }
+
+    @Test
+    @MediumTest
+    fun throwErrorInEffect_doesntRecoverOnInvalidate() {
+        var shouldThrow = true
+        activity.show {
+            TestEffectError { shouldThrow }
+        }
+
+        activity.waitForAFrame()
+
+        run {
+            val start = effectErrorInvoked
+            val errors = compositionErrors()
+            assertThat(errors).hasSize(1)
+            assertThat(errors[0].first.message).isEqualTo("Effect error!")
+            assertThat(errors[0].second).isEqualTo(false)
+
+            shouldThrow = false
+            invalidateGroup(effectErrorKey)
+
+            assertTrue("TestEffectError should not be invoked!", effectErrorInvoked == start)
+        }
+    }
+
+    @Test
+    @MediumTest
+    fun throwErrorInEffect_recoversOnReload() {
+        var shouldThrow = true
+        activity.show {
+            TestEffectError { shouldThrow }
+        }
+
+        activity.waitForAFrame()
+
+        run {
+            val start = effectErrorInvoked
+            var errors = compositionErrors()
+            assertThat(errors).hasSize(1)
+            assertThat(errors[0].first.message).isEqualTo("Effect error!")
+            assertThat(errors[0].second).isEqualTo(false)
+
+            shouldThrow = false
+            simulateHotReload(Unit)
+
+            assertTrue("TestEffectError should be invoked!", effectErrorInvoked > start)
+
+            errors = compositionErrors()
+            assertThat(errors).hasSize(0)
+        }
+    }
 }
 
 const val someFunctionKey = -1580285603 // Extracted from .class file
@@ -256,4 +479,28 @@
     RestartableWrapper {
         ReadOnly()
     }
+}
+
+private const val errorKey = -0x3d6d007a // Extracted from .class file
+private var errorInvoked = 0
+@Composable
+fun TestError(shouldThrow: () -> Boolean = { true }) {
+    errorInvoked++
+
+    if (shouldThrow()) {
+        error("Test crash!")
+    }
+}
+
+private const val effectErrorKey = -0x43852062 // Extracted from .class file
+private var effectErrorInvoked = 0
+@Composable
+fun TestEffectError(shouldThrow: () -> Boolean = { true }) {
+    effectErrorInvoked++
+
+    SideEffect {
+        if (shouldThrow()) {
+            error("Effect error!")
+        }
+    }
 }
\ No newline at end of file
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
index d7d4ddd..4e06f54 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composer.kt
@@ -1416,10 +1416,14 @@
         entersStack.clear()
         providersInvalidStack.clear()
         providerUpdates.clear()
-        reader.close()
+        if (!reader.closed) { reader.close() }
+        if (!writer.closed) { writer.close() }
+        createFreshInsertTable()
         compoundKeyHash = 0
         childrenComposing = 0
         nodeExpected = false
+        inserting = false
+        reusing = false
         isComposing = false
         forciblyRecompose = false
     }
@@ -2798,53 +2802,73 @@
         // All movable content has a compound hash value rooted at the content itself so the hash
         // value doesn't change as the content moves in the tree.
         val savedCompoundKeyHash = compoundKeyHash
-        compoundKeyHash = movableContentKey
 
-        if (inserting) writer.markGroup()
+        try {
+            compoundKeyHash = movableContentKey
 
-        // Capture the local providers at the point of the invocation. This allows detecting
-        // changes to the locals as the value moves well as enables finding the correct providers
-        // when applying late changes which might be very complicated otherwise.
-        val providersChanged = if (inserting) false else reader.groupAux != locals
-        if (providersChanged) providerUpdates[reader.currentGroup] = locals
-        start(compositionLocalMapKey, compositionLocalMap, false, locals)
+            if (inserting) writer.markGroup()
 
-        // Either insert a place-holder to be inserted later (either created new or moved from
-        // another location) or (re)compose the movable content. This is forced if a new value
-        // needs to be created as a late change.
-        if (inserting && !force) {
-            writerHasAProvider = true
-            providerCache = null
+            // Capture the local providers at the point of the invocation. This allows detecting
+            // changes to the locals as the value moves well as enables finding the correct providers
+            // when applying late changes which might be very complicated otherwise.
+            val providersChanged = if (inserting) false else reader.groupAux != locals
+            if (providersChanged) providerUpdates[reader.currentGroup] = locals
+            start(compositionLocalMapKey, compositionLocalMap, false, locals)
 
-            // Create an anchor to the movable group
-            val anchor = writer.anchor(writer.parent(writer.parent))
-            val reference = MovableContentStateReference(
-                content,
-                parameter,
-                composition,
-                insertTable,
-                anchor,
-                emptyList(),
-                currentCompositionLocalScope()
-            )
-            parentContext.insertMovableContent(reference)
-        } else {
-            val savedProvidersInvalid = providersInvalid
-            providersInvalid = providersChanged
-            invokeComposable(this, { content.content(parameter) })
-            providersInvalid = savedProvidersInvalid
+            // Either insert a place-holder to be inserted later (either created new or moved from
+            // another location) or (re)compose the movable content. This is forced if a new value
+            // needs to be created as a late change.
+            if (inserting && !force) {
+                writerHasAProvider = true
+                providerCache = null
+
+                // Create an anchor to the movable group
+                val anchor = writer.anchor(writer.parent(writer.parent))
+                val reference = MovableContentStateReference(
+                    content,
+                    parameter,
+                    composition,
+                    insertTable,
+                    anchor,
+                    emptyList(),
+                    currentCompositionLocalScope()
+                )
+                parentContext.insertMovableContent(reference)
+            } else {
+                val savedProvidersInvalid = providersInvalid
+                providersInvalid = providersChanged
+                invokeComposable(this, { content.content(parameter) })
+                providersInvalid = savedProvidersInvalid
+            }
+        } finally {
+            // Restore the state back to what is expected by the caller.
+            endGroup()
+            compoundKeyHash = savedCompoundKeyHash
+            endMovableGroup()
         }
-
-        // Restore the state back to what is expected by the caller.
-        endGroup()
-        compoundKeyHash = savedCompoundKeyHash
-        endMovableGroup()
     }
 
     @InternalComposeApi
     override fun insertMovableContentReferences(
         references: List<Pair<MovableContentStateReference, MovableContentStateReference?>>
     ) {
+        var completed = false
+        try {
+            insertMovableContentGuarded(references)
+            completed = true
+        } finally {
+            if (completed) {
+                cleanUpCompose()
+            } else {
+                // if we finished with error, cleanup more aggressively
+                abortRoot()
+            }
+        }
+    }
+
+    private fun insertMovableContentGuarded(
+        references: List<Pair<MovableContentStateReference, MovableContentStateReference?>>
+    ) {
         fun positionToParentOf(slots: SlotWriter, applier: Applier<Any?>, index: Int) {
             while (!slots.indexInParent(index)) {
                 slots.skipToGroupEnd()
@@ -2988,7 +3012,7 @@
                     // Copy the slot table into the anchor location
                     record { _, slots, _ ->
                         val state = resolvedState ?: parentContext.movableContentStateResolve(from)
-                            ?: composeRuntimeError("Could not resolve state for movable content")
+                        ?: composeRuntimeError("Could not resolve state for movable content")
 
                         // The slot table contains the movable content group plus the group
                         // containing the movable content's table which then contains the actual
@@ -3054,7 +3078,6 @@
             }
             writersReaderDelta = 0
         }
-        cleanUpCompose()
     }
 
     private inline fun <R> withChanges(newChanges: MutableList<Change>, block: () -> R): R {
@@ -3632,6 +3655,10 @@
         clearUpdatedNodeCounts()
     }
 
+    internal fun verifyConsistent() {
+        insertTable.verifyWellFormed()
+    }
+
     private var previousRemove = -1
     private var previousMoveFrom = -1
     private var previousMoveTo = -1
@@ -4271,6 +4298,8 @@
 @PublishedApi
 internal const val reuseKey = 207
 
+internal class ComposeRuntimeError(override val message: String) : IllegalStateException()
+
 internal inline fun runtimeCheck(value: Boolean, lazyMessage: () -> Any) {
     if (!value) {
         val message = lazyMessage()
@@ -4281,7 +4310,7 @@
 internal fun runtimeCheck(value: Boolean) = runtimeCheck(value) { "Check failed" }
 
 internal fun composeRuntimeError(message: String): Nothing {
-    error(
+    throw ComposeRuntimeError(
         "Compose Runtime internal error. Unexpected or incorrect use of the Compose " +
             "internal runtime API ($message). Please report to Google or use " +
             "https://goo.gle/compose-feedback"
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
index 65a96b1..a32afee 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/Composition.kt
@@ -543,14 +543,16 @@
             null -> {
                 // Do nothing, just start composing.
             }
-            PendingApplyNoModifications -> error("pending composition has not been applied")
+            PendingApplyNoModifications -> {
+                composeRuntimeError("pending composition has not been applied")
+            }
             is Set<*> -> {
                 addPendingInvalidationsLocked(toRecord as Set<Any>, forgetConditionalScopes = true)
             }
             is Array<*> -> for (changed in toRecord as Array<Set<Any>>) {
                 addPendingInvalidationsLocked(changed, forgetConditionalScopes = true)
             }
-            else -> error("corrupt pendingModifications drain: $pendingModifications")
+            else -> composeRuntimeError("corrupt pendingModifications drain: $pendingModifications")
         }
     }
 
@@ -566,10 +568,10 @@
             is Array<*> -> for (changed in toRecord as Array<Set<Any>>) {
                 addPendingInvalidationsLocked(changed, forgetConditionalScopes = false)
             }
-            null -> error(
+            null -> composeRuntimeError(
                 "calling recordModificationsOf and applyChanges concurrently is not supported"
             )
-            else -> error(
+            else -> composeRuntimeError(
                 "corrupt pendingModifications drain: $pendingModifications"
             )
         }
@@ -578,10 +580,12 @@
     override fun composeContent(content: @Composable () -> Unit) {
         // TODO: This should raise a signal to any currently running recompose calls
         // to halt and return
-        trackAbandonedValues {
+        guardChanges {
             synchronized(lock) {
                 drainPendingModificationsForCompositionLocked()
-                composer.composeContent(takeInvalidations(), content)
+                guardInvalidationsLocked { invalidations ->
+                    composer.composeContent(invalidations, content)
+                }
             }
         }
     }
@@ -744,10 +748,12 @@
 
     override fun recompose(): Boolean = synchronized(lock) {
         drainPendingModificationsForCompositionLocked()
-        trackAbandonedValues {
-            composer.recompose(takeInvalidations()).also { shouldDrain ->
-                // Apply would normally do this for us; do it now if apply shouldn't happen.
-                if (!shouldDrain) drainPendingModificationsLocked()
+        guardChanges {
+            guardInvalidationsLocked { invalidations ->
+                composer.recompose(invalidations).also { shouldDrain ->
+                    // Apply would normally do this for us; do it now if apply shouldn't happen.
+                    if (!shouldDrain) drainPendingModificationsLocked()
+                }
             }
         }
     }
@@ -756,7 +762,7 @@
         references: List<Pair<MovableContentStateReference, MovableContentStateReference?>>
     ) {
         runtimeCheck(references.fastAll { it.first.composition == this })
-        trackAbandonedValues {
+        guardChanges {
             composer.insertMovableContentReferences(references)
         }
     }
@@ -812,30 +818,63 @@
 
     override fun applyChanges() {
         synchronized(lock) {
-            applyChangesInLocked(changes)
-            drainPendingModificationsLocked()
+            guardChanges {
+                applyChangesInLocked(changes)
+                drainPendingModificationsLocked()
+            }
         }
     }
 
     override fun applyLateChanges() {
         synchronized(lock) {
-            if (lateChanges.isNotEmpty()) {
-                applyChangesInLocked(lateChanges)
+            guardChanges {
+                if (lateChanges.isNotEmpty()) {
+                    applyChangesInLocked(lateChanges)
+                }
             }
         }
     }
 
     override fun changesApplied() {
         synchronized(lock) {
-            composer.changesApplied()
+            guardChanges {
+                composer.changesApplied()
 
-            // By this time all abandon objects should be notified that they have been abandoned.
-            if (this.abandonSet.isNotEmpty()) {
-                RememberEventDispatcher(abandonSet).dispatchAbandons()
+                // By this time all abandon objects should be notified that they have been abandoned.
+                if (this.abandonSet.isNotEmpty()) {
+                    RememberEventDispatcher(abandonSet).dispatchAbandons()
+                }
             }
         }
     }
 
+    private inline fun <T> guardInvalidationsLocked(
+        block: (changes: IdentityArrayMap<RecomposeScopeImpl, IdentityArraySet<Any>?>) -> T
+    ): T {
+        val invalidations = takeInvalidations()
+        return try {
+            block(invalidations)
+        } catch (e: Exception) {
+            this.invalidations = invalidations
+            throw e
+        }
+    }
+
+    private inline fun <T> guardChanges(block: () -> T): T =
+        try {
+            trackAbandonedValues(block)
+        } catch (e: Exception) {
+            abandonChanges()
+            throw e
+        }
+
+    private fun abandonChanges() {
+        pendingModifications.set(null)
+        changes.clear()
+        lateChanges.clear()
+        abandonSet.clear()
+    }
+
     override fun invalidateAll() {
         synchronized(lock) {
             slotTable.slots.forEach { (it as? RecomposeScopeImpl)?.invalidate() }
@@ -845,6 +884,7 @@
     override fun verifyConsistent() {
         synchronized(lock) {
             if (!isComposing) {
+                composer.verifyConsistent()
                 slotTable.verifyWellFormed()
                 validateRecomposeScopeAnchors(slotTable)
             }
@@ -1094,6 +1134,16 @@
         internal fun invalidateGroupsWithKey(key: Int) {
             return Recomposer.invalidateGroupsWithKey(key)
         }
+
+        @TestOnly
+        internal fun getCurrentErrors(): List<RecomposerErrorInfo> {
+            return Recomposer.getCurrentErrors()
+        }
+
+        @TestOnly
+        internal fun clearErrors() {
+            return Recomposer.clearErrors()
+        }
     }
 }
 
@@ -1109,6 +1159,22 @@
 @TestOnly
 fun invalidateGroupsWithKey(key: Int) = HotReloader.invalidateGroupsWithKey(key)
 
+/**
+ * @suppress
+ */
+// suppressing for test-only api
+@Suppress("ListIterator")
+@TestOnly
+fun currentCompositionErrors(): List<Pair<Exception, Boolean>> =
+    HotReloader.getCurrentErrors()
+        .map { it.cause to it.recoverable }
+
+/**
+ * @suppress
+ */
+@TestOnly
+fun clearCompositionErrors() = HotReloader.clearErrors()
+
 private fun <K : Any, V : Any> IdentityArrayMap<K, IdentityArraySet<V>?>.addValue(
     key: K,
     value: V
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 efe1d06..c4b90f1 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
@@ -99,6 +99,24 @@
 }
 
 /**
+ * Read only information about [Recomposer] error state.
+ */
+@InternalComposeApi
+internal interface RecomposerErrorInfo {
+    /**
+     * Exception which forced recomposition to halt.
+     */
+    val cause: Exception
+
+    /**
+     * Whether composition can recover from the error by itself.
+     * If the error is not recoverable, recomposer will not react to invalidate calls
+     * until state is reloaded.
+     */
+    val recoverable: Boolean
+}
+
+/**
  * The scheduler for performing recomposition and applying updates to one or more [Composition]s.
  */
 // RedundantVisibilityModifier suppressed because metalava picks up internal function overrides
@@ -246,6 +264,7 @@
     private var workContinuation: CancellableContinuation<Unit>? = null
     private var concurrentCompositionsOutstanding = 0
     private var isClosed: Boolean = false
+    private var errorState: RecomposerErrorState? = null
     // End properties guarded by stateLock
 
     private val _state = MutableStateFlow(State.Inactive)
@@ -267,6 +286,9 @@
         }
 
         val newState = when {
+            errorState != null -> {
+                State.Inactive
+            }
             runnerJob == null -> {
                 snapshotInvalidations.clear()
                 compositionInvalidations.clear()
@@ -318,6 +340,11 @@
             get() = this@Recomposer.hasPendingWork
         override val changeCount: Long
             get() = this@Recomposer.changeCount
+        val currentError: RecomposerErrorInfo?
+            get() = synchronized(stateLock) {
+                this@Recomposer.errorState
+            }
+
         fun invalidateGroupsWithKey(key: Int) {
             val compositions: List<ControlledComposition> = synchronized(stateLock) {
                 knownCompositions.toMutableList()
@@ -334,6 +361,9 @@
                 .fastMapNotNull { it as? CompositionImpl }
                 .fastMap { HotReloadable(it).apply { clearContent() } }
         }
+
+        fun getAndResetErrorState(): RecomposerErrorState? =
+            this@Recomposer.getAndResetErrorState()
     }
 
     private class HotReloadable(
@@ -350,13 +380,23 @@
             composition.composable = composable
         }
 
-        fun recompose() {
-            if (composition.isRoot) {
+        fun recompose(rootOnly: Boolean = true) {
+            if (rootOnly) {
+                if (composition.isRoot) {
+                    composition.setContent(composable)
+                }
+            } else {
                 composition.setContent(composable)
             }
         }
     }
 
+    private class RecomposerErrorState(
+        val failedInitialComposition: HotReloadable?,
+        override val recoverable: Boolean,
+        override val cause: Exception
+    ) : RecomposerErrorInfo
+
     private val recomposerInfo = RecomposerInfoImpl()
 
     /**
@@ -424,6 +464,14 @@
         val toLateApply = mutableSetOf<ControlledComposition>()
         val toComplete = mutableSetOf<ControlledComposition>()
 
+        fun clearRecompositionState() {
+            toRecompose.clear()
+            toInsert.clear()
+            toApply.clear()
+            toLateApply.clear()
+            toComplete.clear()
+        }
+
         fun fillToInsert() {
             toInsert.clear()
             synchronized(stateLock) {
@@ -486,6 +534,10 @@
                                     toApply += it
                                 }
                             }
+                        } catch (e: Exception) {
+                            processCompositionError(e, recoverable = true)
+                            clearRecompositionState()
+                            return@withFrameNanos
                         } finally {
                             toRecompose.clear()
                         }
@@ -508,10 +560,16 @@
                         }
 
                         if (toRecompose.isEmpty()) {
-                            fillToInsert()
-                            while (toInsert.isNotEmpty()) {
-                                toLateApply += performInsertValues(toInsert, modifiedValues)
+                            try {
                                 fillToInsert()
+                                while (toInsert.isNotEmpty()) {
+                                    toLateApply += performInsertValues(toInsert, modifiedValues)
+                                    fillToInsert()
+                                }
+                            } catch (e: Exception) {
+                                processCompositionError(e, recoverable = true)
+                                clearRecompositionState()
+                                return@withFrameNanos
                             }
                         }
                     }
@@ -525,6 +583,10 @@
                             toApply.fastForEach { composition ->
                                 composition.applyChanges()
                             }
+                        } catch (e: Exception) {
+                            processCompositionError(e)
+                            clearRecompositionState()
+                            return@withFrameNanos
                         } finally {
                             toApply.clear()
                         }
@@ -536,6 +598,10 @@
                             toLateApply.forEach { composition ->
                                 composition.applyLateChanges()
                             }
+                        } catch (e: Exception) {
+                            processCompositionError(e)
+                            clearRecompositionState()
+                            return@withFrameNanos
                         } finally {
                             toLateApply.clear()
                         }
@@ -546,6 +612,10 @@
                             toComplete.forEach { composition ->
                                 composition.changesApplied()
                             }
+                        } catch (e: Exception) {
+                            processCompositionError(e)
+                            clearRecompositionState()
+                            return@withFrameNanos
                         } finally {
                             toComplete.clear()
                         }
@@ -561,6 +631,48 @@
         }
     }
 
+    private fun processCompositionError(
+        e: Exception,
+        failedInitialComposition: ControlledComposition? = null,
+        recoverable: Boolean = false,
+    ) {
+        if (_hotReloadEnabled.get() && e !is ComposeRuntimeError) {
+            synchronized(stateLock) {
+                compositionsAwaitingApply.clear()
+                compositionInvalidations.clear()
+                snapshotInvalidations.clear()
+
+                compositionValuesAwaitingInsert.clear()
+                compositionValuesRemoved.clear()
+                compositionValueStatesAvailable.clear()
+
+                errorState = RecomposerErrorState(
+                    failedInitialComposition = (failedInitialComposition as? CompositionImpl)?.let {
+                        HotReloadable(it)
+                    },
+                    recoverable = recoverable,
+                    cause = e
+                )
+
+                deriveStateLocked()
+            }
+        } else {
+            throw e
+        }
+    }
+
+    private fun getAndResetErrorState(): RecomposerErrorState? {
+        val errorState = synchronized(stateLock) {
+            val error = errorState
+            if (error != null) {
+                errorState = null
+                deriveStateLocked()
+            }
+            error
+        }
+        return errorState
+    }
+
     /**
      * Await the invalidation of any associated [Composer]s, recompose them, and apply their
      * changes to their associated [Composition]s if recomposition is successful.
@@ -807,9 +919,15 @@
         content: @Composable () -> Unit
     ) {
         val composerWasComposing = composition.isComposing
-        composing(composition, null) {
-            composition.composeContent(content)
+        try {
+            composing(composition, null) {
+                composition.composeContent(content)
+            }
+        } catch (e: Exception) {
+            processCompositionError(e, composition, recoverable = true)
+            return
         }
+
         // TODO(b/143755743)
         if (!composerWasComposing) {
             Snapshot.notifyObjectsInitialized()
@@ -823,9 +941,23 @@
             }
         }
 
-        performInitialMovableContentInserts(composition)
-        composition.applyChanges()
-        composition.applyLateChanges()
+        try {
+            performInitialMovableContentInserts(composition)
+        } catch (e: Exception) {
+            processCompositionError(e, composition, recoverable = true)
+            synchronized(stateLock) {
+                knownCompositions -= composition
+            }
+            return
+        }
+
+        try {
+            composition.applyChanges()
+            composition.applyLateChanges()
+        } catch (e: Exception) {
+            processCompositionError(e)
+            return
+        }
 
         if (!composerWasComposing) {
             // Ensure that any state objects created during applyChanges are seen as changed
@@ -888,7 +1020,6 @@
             composing(composition, modifiedValues) {
                 // Map insert movable content to movable content states that have been released
                 // during `performRecompose`.
-                // during `performRecompose`.
                 val pairs = synchronized(stateLock) {
                     refs.fastMap { reference ->
                         reference to
@@ -1077,6 +1208,8 @@
 
         private val _runningRecomposers = MutableStateFlow(persistentSetOf<RecomposerInfoImpl>())
 
+        private val _hotReloadEnabled = AtomicReference(false)
+
         /**
          * An observable [Set] of [RecomposerInfo]s for currently
          * [running][runRecomposeAndApplyChanges] [Recomposer]s.
@@ -1085,6 +1218,10 @@
         val runningRecomposers: StateFlow<Set<RecomposerInfo>>
             get() = _runningRecomposers
 
+        internal fun setHotReloadEnabled(value: Boolean) {
+            _hotReloadEnabled.set(value)
+        }
+
         private fun addRunning(info: RecomposerInfoImpl) {
             while (true) {
                 val old = _runningRecomposers.value
@@ -1104,21 +1241,58 @@
         internal fun saveStateAndDisposeForHotReload(): Any {
             // NOTE: when we move composition/recomposition onto multiple threads, we will want
             // to ensure that we pause recompositions before this call.
+            _hotReloadEnabled.set(true)
             return _runningRecomposers.value.flatMap { it.saveStateAndDisposeForHotReload() }
         }
 
         internal fun loadStateAndComposeForHotReload(token: Any) {
             // NOTE: when we move composition/recomposition onto multiple threads, we will want
             // to ensure that we pause recompositions before this call.
+            _hotReloadEnabled.set(true)
+
+            val errorStates = _runningRecomposers.value.map {
+                it.getAndResetErrorState()
+            }
+
             @Suppress("UNCHECKED_CAST")
             val holders = token as List<HotReloadable>
             holders.fastForEach { it.resetContent() }
             holders.fastForEach { it.recompose() }
+
+            errorStates.fastForEach {
+                 it?.failedInitialComposition?.let { c ->
+                     c.resetContent()
+                     c.recompose(rootOnly = false)
+                 }
+            }
         }
 
         internal fun invalidateGroupsWithKey(key: Int) {
+            _hotReloadEnabled.set(true)
             _runningRecomposers.value.forEach {
+                if (it.currentError?.recoverable == false) {
+                    return@forEach
+                }
+
+                val errorState = it.getAndResetErrorState()
+
                 it.invalidateGroupsWithKey(key)
+
+                errorState?.failedInitialComposition?.let { c ->
+                    c.resetContent()
+                    c.recompose(rootOnly = false)
+                }
+            }
+        }
+
+        internal fun getCurrentErrors(): List<RecomposerErrorInfo> =
+            _runningRecomposers.value.mapNotNull {
+                it.currentError
+            }
+
+        internal fun clearErrors() {
+            _runningRecomposers.value.mapNotNull {
+                it.getAndResetErrorState()
             }
         }
     }
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
index b60b182..53719e5 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/SlotTable.kt
@@ -248,7 +248,7 @@
      * Close [reader].
      */
     internal fun close(reader: SlotReader) {
-        require(reader.table === this && readers > 0) { "Unexpected reader close()" }
+        runtimeCheck(reader.table === this && readers > 0) { "Unexpected reader close()" }
         readers--
     }
 
@@ -644,6 +644,12 @@
     private val slotsSize: Int = table.slotsSize
 
     /**
+     * True if the reader has been closed
+     */
+    var closed: Boolean = false
+        private set
+
+    /**
      * The current group that will be started with [startGroup] or skipped with [skipGroup].
      */
     var currentGroup = 0
@@ -895,7 +901,10 @@
      * Close the slot reader. After all [SlotReader]s have been closed the [SlotTable] a
      * [SlotWriter] can be created.
      */
-    fun close() = table.close(this)
+    fun close() {
+        closed = true
+        table.close(this)
+    }
 
     /**
      * Start a group.
diff --git a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/LiveEditTests.kt b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/LiveEditTests.kt
index 6e7b05e..3e2426c 100644
--- a/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/LiveEditTests.kt
+++ b/compose/runtime/runtime/src/jvmTest/kotlin/androidx/compose/runtime/LiveEditTests.kt
@@ -18,12 +18,25 @@
 
 import androidx.compose.runtime.mock.Text
 import androidx.compose.runtime.mock.compositionTest
+import org.junit.After
 import org.junit.Assert
+import org.junit.Before
 import org.junit.Ignore
 import org.junit.Test
 
 class LiveEditTests {
 
+    @Before
+    fun setUp() {
+        Recomposer.setHotReloadEnabled(true)
+    }
+
+    @After
+    fun tearDown() {
+        clearCompositionErrors()
+        Recomposer.setHotReloadEnabled(true)
+    }
+
     @Test
     fun testRestartableFunctionPreservesParentAndSiblingState() = liveEditTest {
         EnsureStatePreservedAndNotRecomposed("a")
@@ -130,6 +143,323 @@
             Text("Hello World")
         }
     }
+
+    @Test
+    fun testThrowing_initialComposition() = liveEditTest {
+        RestartGroup {
+            MarkAsTarget()
+            // Fail once per each reload
+            expectError("throwInCompose", 2)
+            // Composed once - failed once
+            Expect(
+                "throw",
+                compose = 2,
+                onRememberd = 0,
+                onForgotten = 0,
+                onAbandoned = 2
+            )
+            error("throwInCompose")
+        }
+    }
+
+    @Test
+    fun testThrowing_recomposition() {
+        var recomposeCount = 0
+        liveEditTest(reloadCount = 2) {
+            RestartGroup {
+                MarkAsTarget()
+
+                // only failed on 2nd recomposition
+                expectError("throwInCompose", 1)
+                // Composed 3 times, failed once
+                Expect(
+                    "throw",
+                    compose = 3,
+                    onRememberd = 2,
+                    onForgotten = 1,
+                    onAbandoned = 1
+                )
+
+                recomposeCount++
+                if (recomposeCount == 2) {
+                    error("throwInCompose")
+                }
+            }
+        }
+    }
+
+    @Test
+    fun testThrowing_initialComposition_sideEffect() {
+        liveEditTest {
+            RestartGroup {
+                MarkAsTarget()
+
+                // The error is not recoverable, so reload doesn't fix the error
+                expectError("throwInEffect", 1)
+
+                // Composition happens as usual
+                Expect(
+                    "a",
+                    compose = 1,
+                    onRememberd = 1,
+                    onForgotten = 0,
+                    onAbandoned = 0,
+                )
+
+                SideEffect {
+                    error("throwInEffect")
+                }
+            }
+        }
+    }
+
+    @Test
+    fun testThrowing_recomposition_sideEffect() {
+        var recomposeCount = 0
+        liveEditTest {
+            RestartGroup {
+                MarkAsTarget()
+
+                // The error is not recoverable, so reload doesn't fix the error
+                expectError("throwInEffect", 1)
+
+                // Composition happens as usual
+                Expect(
+                    "a",
+                    compose = 2,
+                    onRememberd = 2,
+                    onForgotten = 1,
+                    onAbandoned = 0,
+                )
+
+                recomposeCount++
+
+                SideEffect {
+                    if (recomposeCount == 2) {
+                        error("throwInEffect")
+                    }
+                }
+            }
+        }
+    }
+
+    @Test
+    fun testThrowing_initialComposition_remembered() {
+        liveEditTest {
+            RestartGroup {
+                MarkAsTarget()
+
+                // The error is not recoverable, so reload doesn't fix the error
+                expectError("throwOnRemember", 1)
+
+                // remembers as usual
+                Expect(
+                    "a",
+                    compose = 1,
+                    onRememberd = 1,
+                    onForgotten = 0,
+                    onAbandoned = 0,
+                )
+
+                remember {
+                    object : RememberObserver {
+                        override fun onRemembered() {
+                            error("throwOnRemember")
+                        }
+                        override fun onForgotten() {}
+                        override fun onAbandoned() {}
+                    }
+                }
+
+                // The rest of remembers fail
+                Expect(
+                    "b",
+                    compose = 1,
+                    onRememberd = 0,
+                    onForgotten = 0,
+                    onAbandoned = 1,
+                )
+            }
+        }
+    }
+
+    @Test
+    fun testThrowing_recomposition_remembered() {
+        var recomposeCount = 0
+        liveEditTest {
+            RestartGroup {
+                MarkAsTarget()
+
+                // The error is not recoverable, so reload doesn't fix the error
+                expectError("throwOnRemember", 1)
+
+                recomposeCount++
+
+                // remembers as usual
+                Expect(
+                    "a",
+                    compose = 2,
+                    onRememberd = 2,
+                    onForgotten = 1,
+                    onAbandoned = 0,
+                )
+
+                remember {
+                    object : RememberObserver {
+                        override fun onRemembered() {
+                            if (recomposeCount == 2) {
+                                error("throwOnRemember")
+                            }
+                        }
+                        override fun onForgotten() {}
+                        override fun onAbandoned() {}
+                    }
+                }
+
+                // The rest of remembers fail
+                Expect(
+                    "b",
+                    compose = 2,
+                    onRememberd = 1,
+                    // todo: ensure forgotten is not dispatched for abandons?
+                    onForgotten = 1,
+                    onAbandoned = 1,
+                )
+            }
+        }
+    }
+
+    @Test
+    fun testThrowing_invalidationsCarriedAfterCrash() {
+        var recomposeCount = 0
+        val state = mutableStateOf(0)
+        liveEditTest(reloadCount = 2) {
+            RestartGroup {
+                RestartGroup {
+                    MarkAsTarget()
+
+                    // Only error the first time
+                    expectError("throwInComposition", 1)
+
+                    if (recomposeCount == 0) {
+                        // invalidate sibling group below in first composition
+                        state.value += 1
+                    }
+
+                    if (recomposeCount++ == 1) {
+                        // crash after first reload
+                        error("throwInComposition")
+                    }
+                }
+            }
+
+            RestartGroup {
+                // read state
+                state.value
+
+                // composed initially + invalidated by crashed composition
+                Expect(
+                    "state",
+                    compose = 2,
+                    onRememberd = 1,
+                    onForgotten = 0,
+                    onAbandoned = 0
+                )
+            }
+        }
+    }
+
+    @Test
+    fun testThrowing_movableContent() {
+        liveEditTest {
+            RestartGroup {
+                MarkAsTarget()
+
+                expectError("throwInMovableContent", 2)
+
+                val content = remember {
+                    movableContentOf {
+                        error("throwInMovableContent")
+                    }
+                }
+
+                content()
+            }
+        }
+    }
+
+    @Test
+    fun testThrowing_movableContent_recomposition() {
+        var recomposeCount = 0
+        liveEditTest(reloadCount = 2) {
+            RestartGroup {
+                MarkAsTarget()
+
+                expectError("throwInMovableContent", 1)
+
+                val content = remember {
+                    movableContentOf {
+                        Expect(
+                            "movable",
+                            compose = 3,
+                            onRememberd = 2,
+                            onForgotten = 1,
+                            onAbandoned = 1
+                        )
+
+                        if (recomposeCount == 1) {
+                            error("throwInMovableContent")
+                        }
+                    }
+                }
+
+                content()
+
+                recomposeCount++
+            }
+        }
+    }
+
+    @Test
+    fun testThrowing_movableContent_throwAfterMove() {
+        var recomposeCount = 0
+        liveEditTest(reloadCount = 2) {
+            expectError("throwInMovableContent", 1)
+
+            val content = remember {
+                movableContentOf {
+                    recomposeCount++
+                    Expect(
+                        "movable",
+                        compose = 4,
+                        onRememberd = 3,
+                        onForgotten = 2,
+                        onAbandoned = 1
+                    )
+
+                    if (recomposeCount == 1) {
+                        error("throwInMovableContent")
+                    }
+                }
+            }
+
+            RestartGroup {
+                MarkAsTarget()
+
+                if (recomposeCount == 0) {
+                    content()
+                }
+            }
+
+            RestartGroup {
+                MarkAsTarget()
+
+                if (recomposeCount > 0) {
+                    content()
+                }
+            }
+        }
+    }
 }
 
 @Composable
@@ -237,19 +567,52 @@
     addTargetKey((currentComposer as ComposerImpl).parentKey())
 }
 
-fun liveEditTest(fn: @Composable LiveEditTestScope.() -> Unit) = compositionTest {
+@OptIn(InternalComposeApi::class)
+fun liveEditTest(
+    reloadCount: Int = 1,
+    fn: @Composable LiveEditTestScope.() -> Unit,
+) = compositionTest {
     with(LiveEditTestScope()) {
-        compose { fn(this) }
-        invalidateTargets()
-        advance()
+        addCheck {
+            (composition as? ControlledComposition)?.verifyConsistent()
+        }
+
+        recordErrors {
+            compose { fn(this) }
+        }
+
+        repeat(reloadCount) {
+            invalidateTargets()
+            recordErrors {
+                advance()
+            }
+        }
+
         runChecks()
     }
 }
 
+@OptIn(InternalComposeApi::class)
+private inline fun LiveEditTestScope.recordErrors(
+    block: () -> Unit
+) {
+    try {
+        block()
+    } catch (e: ComposeRuntimeError) {
+        throw e
+    } catch (e: Exception) {
+        addError(e)
+    }
+    currentCompositionErrors().forEach {
+        addError(it.first)
+    }
+}
+
 @Stable
 class LiveEditTestScope {
     private val targetKeys = mutableSetOf<Int>()
     private val checks = mutableListOf<() -> Unit>()
+    private val errors = mutableSetOf<Exception>()
     private val logs = mutableListOf<Pair<String, String>>()
 
     fun invalidateTargets() {
@@ -271,13 +634,18 @@
     fun log(ref: String, msg: String) {
         logs.add(ref to msg)
     }
-    fun addLogCheck(ref: String, validate: (List<String>) -> Unit) {
-        checks.add {
-            validate(logs.filter { it.first == ref }.map { it.second }.toList())
-        }
+
+    fun addError(e: Exception) {
+        errors.add(e)
     }
+
+    fun addCheck(check: () -> Unit) {
+        checks.add(check)
+    }
+
     fun expectLogCount(ref: String, msg: String, expected: Int) {
-        addLogCheck(ref) { logs ->
+        addCheck {
+            val logs = logs.filter { it.first == ref }.map { it.second }.toList()
             val actual = logs.filter { m -> m == msg }.count()
             Assert.assertEquals(
                 "Ref '$ref' had an unexpected # of '$msg' logs",
@@ -286,4 +654,15 @@
             )
         }
     }
+
+    fun expectError(message: String, count: Int) {
+        addCheck {
+            val errors = errors.filter { it.message == message }
+            Assert.assertEquals(
+                "Got ${errors.size} errors with $message",
+                count,
+                errors.size
+            )
+        }
+    }
 }