diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt
index 9285d413f4..76220acbfd 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeHost.kt
@@ -16,7 +16,7 @@
package app.cash.redwood.treehouse
/** Manages loading and hot-reloading a series of code sessions. */
-internal interface CodeHost {
+internal interface CodeHost : CodeListener.CrashResponse {
val stateStore: StateStore
/** Only accessed on [TreehouseDispatchers.ui]. */
@@ -29,6 +29,7 @@ internal interface CodeHost {
fun removeListener(listener: Listener)
interface Listener {
+ fun uncaughtException(exception: Throwable)
fun codeSessionChanged(next: CodeSession)
}
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt
index 426d78a04a..77f869bfa1 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeListener.kt
@@ -32,4 +32,29 @@ public open class CodeListener {
* @param initial true if this is the first code loaded for this view's current content.
*/
public open fun onCodeLoaded(view: TreehouseView<*>, initial: Boolean) {}
+
+ /**
+ * Invoked when the application powering [view] fails with an uncaught exception. This function
+ * should display an error UI; use [EventListener.onUncaughtException] to track failures.
+ *
+ * Typical implementations call [TreehouseView.reset] and display an error placeholder.
+ * Development builds may show more diagnostic information than production builds.
+ *
+ * When a Treehouse app fails, its current [Zipline] instance is canceled so no further code will
+ * execute. A new [Zipline] will start when new code available, or when the app is restarted
+ * with [CrashResponse.restart].
+ *
+ * This condition is not permanent! If new code is loaded after an error, [onCodeLoaded] will be
+ * called.
+ */
+ public open fun onUncaughtException(
+ view: TreehouseView<*>,
+ e: Throwable,
+ crashResponse: CrashResponse,
+ ) {
+ }
+
+ public interface CrashResponse {
+ public fun restart()
+ }
}
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
index 39b5f7469b..56e9926fdb 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseApp.kt
@@ -23,7 +23,9 @@ import app.cash.zipline.loader.ZiplineCache
import app.cash.zipline.loader.ZiplineHttpClient
import app.cash.zipline.loader.ZiplineLoader
import app.cash.zipline.withScope
+import kotlin.coroutines.CoroutineContext
import kotlin.native.ObjCName
+import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
@@ -46,7 +48,10 @@ public class TreehouseApp private constructor(
private val appScope: CoroutineScope,
public val spec: Spec,
) {
- public val dispatchers: TreehouseDispatchers = factory.dispatchers
+ private val codeHost = ZiplineCodeHost()
+
+ public val dispatchers: TreehouseDispatchers = factory.dispatchers.withExceptionHandler(codeHost)
+
private val eventPublisher = RealEventPublisher(factory.eventListener, this)
private var started = false
@@ -54,14 +59,6 @@ public class TreehouseApp private constructor(
/** Only accessed on [TreehouseDispatchers.zipline]. */
private var closed = false
- private val codeHost = ZiplineCodeHost(
- appScope = appScope,
- dispatchers = dispatchers,
- eventPublisher = eventPublisher,
- frameClock = factory.frameClock,
- stateStore = factory.stateStore,
- )
-
/**
* Returns the current zipline attached to this host, or null if Zipline hasn't loaded yet. The
* returned value will be invalid when new code is loaded.
@@ -128,7 +125,7 @@ public class TreehouseApp private constructor(
private fun ziplineFlow(): Flow {
// Loads applications from the network only. The cache is neither read nor written.
var loader = ZiplineLoader(
- dispatcher = dispatchers.zipline,
+ coroutineContext = dispatchers.zipline,
manifestVerifier = factory.manifestVerifier,
httpClient = factory.httpClient,
eventListener = eventPublisher.ziplineEventListener,
@@ -183,15 +180,16 @@ public class TreehouseApp private constructor(
eventPublisher.appCanceled()
}
- private class ZiplineCodeHost(
- private val appScope: CoroutineScope,
- private val dispatchers: TreehouseDispatchers,
- private val eventPublisher: EventPublisher,
- private val frameClock: FrameClock,
- override val stateStore: StateStore,
- ) : CodeHost {
- override var session: ZiplineCodeSession? = null
+ /** Returns a copy of this that routes uncaught exceptions to [exceptionHandler]. */
+ private fun TreehouseDispatchers.withExceptionHandler(
+ exceptionHandler: CoroutineExceptionHandler,
+ ): TreehouseDispatchers {
+ return object : TreehouseDispatchers by this@withExceptionHandler {
+ override val zipline = this@withExceptionHandler.zipline + exceptionHandler
+ }
+ }
+ private inner class ZiplineCodeHost : CodeHost, CoroutineExceptionHandler {
/**
* Contents that this app is currently responsible for.
*
@@ -199,6 +197,13 @@ public class TreehouseApp private constructor(
*/
private val listeners = mutableListOf>()
+ override val stateStore: StateStore = factory.stateStore
+
+ override var session: ZiplineCodeSession? = null
+
+ override val key: CoroutineContext.Key<*>
+ get() = CoroutineExceptionHandler.Key
+
override fun newServiceScope(): CodeHost.ServiceScope {
val ziplineScope = ZiplineScope()
@@ -213,6 +218,18 @@ public class TreehouseApp private constructor(
}
}
+ override fun handleException(context: CoroutineContext, exception: Throwable) {
+ appScope.launch(dispatchers.ui) {
+ for (listener in listeners) {
+ listener.uncaughtException(exception)
+ }
+ }
+ }
+
+ override fun restart() {
+ // TODO
+ }
+
override fun addListener(listener: CodeHost.Listener) {
dispatchers.checkUi()
listeners += listener
@@ -232,7 +249,7 @@ public class TreehouseApp private constructor(
dispatchers = dispatchers,
eventPublisher = eventPublisher,
appScope = appScope,
- frameClock = frameClock,
+ frameClock = factory.frameClock,
sessionScope = sessionScope,
appService = appService,
zipline = zipline,
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
index 863f0843ed..9fe2714aa0 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt
@@ -182,6 +182,16 @@ internal class TreehouseAppContent(
stateFlow.value = State(nextViewState, nextCodeState)
}
+ override fun uncaughtException(exception: Throwable) {
+ dispatchers.checkUi()
+
+ // TODO: do nothing if canceled, etc.
+
+ val view = (stateFlow.value.viewState as? ViewState.Bound)?.view ?: return
+
+ codeListener.onUncaughtException(view, exception, codeHost)
+ }
+
override fun codeSessionChanged(next: CodeSession) {
dispatchers.checkUi()
diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt
index 88be6596a9..16620c5f7b 100644
--- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt
+++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt
@@ -15,21 +15,21 @@
*/
package app.cash.redwood.treehouse
+import kotlin.coroutines.CoroutineContext
import kotlin.native.ObjCName
-import kotlinx.coroutines.CoroutineDispatcher
/**
* One of the trickiest things Treehouse needs to do is balance its two dispatchers:
*
- * * [ui] is the [CoroutineDispatcher] that runs on the platform's UI thread.
- * * [zipline] is where downloaded code executes.
+ * * [ui] executes dispatched tasks on the platform's UI thread.
+ * * [zipline] executes dispatched tasks on the thread where downloaded code executes.
*
* This class makes it easier to specify invariants on which dispatcher is expected for which work.
*/
@ObjCName("TreehouseDispatchers", exact = true)
public interface TreehouseDispatchers {
- public val ui: CoroutineDispatcher
- public val zipline: CoroutineDispatcher
+ public val ui: CoroutineContext
+ public val zipline: CoroutineContext
/**
* Confirm that this is being called on the UI thread.
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/AbstractTreehouseDispatchersTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/AbstractTreehouseDispatchersTest.kt
index 8e6a968a45..cb0576d306 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/AbstractTreehouseDispatchersTest.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/AbstractTreehouseDispatchersTest.kt
@@ -23,7 +23,6 @@ import kotlin.coroutines.CoroutineContext
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertFailsWith
-import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
@@ -51,7 +50,7 @@ abstract class AbstractTreehouseDispatchersTest {
uncaughtExceptionsCancelTheirJob(treehouseDispatchers.zipline)
}
- private suspend fun uncaughtExceptionsCancelTheirJob(dispatcher: CoroutineDispatcher) {
+ private suspend fun uncaughtExceptionsCancelTheirJob(dispatcher: CoroutineContext) {
val exceptionCollector = ExceptionCollector()
val failingJob = supervisorScope {
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt
index 7d996185a1..f5930ee2f1 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeHost.kt
@@ -15,7 +15,9 @@
*/
package app.cash.redwood.treehouse
-internal class FakeCodeHost : CodeHost {
+internal class FakeCodeHost(
+ private val eventLog: EventLog,
+) : CodeHost {
override val stateStore = MemoryStateStore()
override var session: CodeSession? = null
@@ -54,6 +56,16 @@ internal class FakeCodeHost : CodeHost {
}
}
+ fun triggerException(exception: Throwable) {
+ for (listener in listeners) {
+ listener.uncaughtException(exception)
+ }
+ }
+
+ override fun restart() {
+ eventLog += "restart()"
+ }
+
override fun addListener(listener: CodeHost.Listener) {
listeners += listener
}
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt
index 1f4bca8f2d..9a6cdb2543 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeListener.kt
@@ -18,11 +18,24 @@ package app.cash.redwood.treehouse
class FakeCodeListener(
private val eventLog: EventLog,
) : CodeListener() {
- override fun onInitialCodeLoading(view: TreehouseView<*>) {
+ override fun onInitialCodeLoading(
+ view: TreehouseView<*>,
+ ) {
eventLog += "codeListener.onInitialCodeLoading($view)"
}
- override fun onCodeLoaded(view: TreehouseView<*>, initial: Boolean) {
+ override fun onCodeLoaded(
+ view: TreehouseView<*>,
+ initial: Boolean,
+ ) {
eventLog += "codeListener.onCodeLoaded($view, initial = $initial)"
}
+
+ override fun onUncaughtException(
+ view: TreehouseView<*>,
+ e: Throwable,
+ crashResponse: CrashResponse,
+ ) {
+ eventLog += "codeListener.onUncaughtException($view, $e)"
+ }
}
diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt
index db5d195560..6839409bbe 100644
--- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt
+++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/TreehouseAppContentTest.kt
@@ -33,7 +33,7 @@ class TreehouseAppContentTest {
private val eventLog = EventLog()
private val dispatcher = UnconfinedTestDispatcher()
- private val codeHost = FakeCodeHost()
+ private val codeHost = FakeCodeHost(eventLog)
private val dispatchers = FakeDispatchers(dispatcher, dispatcher)
private val eventPublisher = FakeEventPublisher()
private val codeListener = FakeCodeListener(eventLog)
@@ -293,6 +293,24 @@ class TreehouseAppContentTest {
eventLog.takeEvent("codeSessionB.app.uis[0].close()")
}
+ @Test
+ fun exception_thrown() = runTest {
+ val content = treehouseAppContent()
+
+ codeHost.session = FakeCodeSession("codeSessionA", eventLog)
+ eventLog.takeEvent("codeSessionA.start()")
+
+ val view1 = FakeTreehouseView("view1")
+ content.bind(view1)
+ eventLog.takeEvent("codeSessionA.app.uis[0].start()")
+
+ codeHost.triggerException(Exception("boom!"))
+ eventLog.takeEvent("codeListener.onUncaughtException(view1, java.lang.Exception: boom!)")
+
+ content.unbind()
+ eventLog.takeEvent("codeSessionA.app.uis[0].close()")
+ }
+
private fun TestScope.treehouseAppContent(): TreehouseAppContent {
return TreehouseAppContent(
codeHost = codeHost,