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,