diff --git a/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/StandardAppLifecycle.kt b/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/StandardAppLifecycle.kt index 0cae5a8958..2ca6f0264c 100644 --- a/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/StandardAppLifecycle.kt +++ b/redwood-treehouse-guest/src/commonMain/kotlin/app/cash/redwood/treehouse/StandardAppLifecycle.kt @@ -23,19 +23,17 @@ import app.cash.redwood.protocol.WidgetTag import app.cash.redwood.protocol.guest.ProtocolBridge import app.cash.redwood.protocol.guest.ProtocolMismatchHandler import app.cash.redwood.treehouse.AppLifecycle.Host +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope import kotlinx.serialization.json.Json -@OptIn(DelicateCoroutinesApi::class) public class StandardAppLifecycle( internal val protocolBridgeFactory: ProtocolBridge.Factory, internal val json: Json, internal val widgetVersion: UInt, ) : AppLifecycle { private lateinit var host: Host - internal val coroutineScope: CoroutineScope = GlobalScope private lateinit var broadcastFrameClock: BroadcastFrameClock internal lateinit var frameClock: MonotonicFrameClock @@ -50,6 +48,17 @@ public class StandardAppLifecycle( } } + private val coroutineExceptionHandler = object : CoroutineExceptionHandler { + override val key: CoroutineContext.Key<*> + get() = CoroutineExceptionHandler.Key + + override fun handleException(context: CoroutineContext, exception: Throwable) { + host.handleUncaughtException(exception) + } + } + + internal val coroutineScope = CoroutineScope(coroutineExceptionHandler) + override fun start(host: Host) { this.host = host this.broadcastFrameClock = BroadcastFrameClock { host.requestFrame() } 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 da0ced995c..525566b365 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 @@ -15,6 +15,9 @@ */ package app.cash.redwood.treehouse +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineExceptionHandler + /** Manages loading and hot-reloading a series of code sessions. */ internal interface CodeHost { val stateStore: StateStore @@ -24,6 +27,9 @@ internal interface CodeHost { fun newServiceScope(): ServiceScope + /** Cancels the current code and propagates [exception] to all listeners. */ + fun handleUncaughtException(exception: Throwable) + fun addListener(listener: Listener) fun removeListener(listener: Listener) @@ -46,3 +52,12 @@ internal interface CodeHost { fun close() } } + +internal fun CodeHost<*>.asExceptionHandler() = object : CoroutineExceptionHandler { + override val key: CoroutineContext.Key<*> + get() = CoroutineExceptionHandler.Key + + override fun handleException(context: CoroutineContext, exception: Throwable) { + handleUncaughtException(exception) + } +} diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventListener.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventListener.kt index 7e029b3e1e..d00986ec22 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventListener.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventListener.kt @@ -370,4 +370,20 @@ public open class EventListener { name: String, ) { } + + /** + * Invoked when [app] has thrown an uncaught exception. + * + * This indicates an unrecoverable software bug. Development implementations should report the + * exception to the developer. Production implementations should post the exception to a bug + * tracking service. + * + * 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. + */ + public open fun uncaughtException( + app: TreehouseApp<*>, + exception: Throwable, + ) { + } } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt index 91861042ec..92f8ffd7f4 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/EventPublisher.kt @@ -33,4 +33,6 @@ internal interface EventPublisher { fun onUnknownEvent(widgetTag: WidgetTag, tag: EventTag) fun onUnknownEventNode(id: Id, tag: EventTag) + + fun onUncaughtException(exception: Throwable) } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealEventPublisher.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealEventPublisher.kt index ec9f94541b..c527e3514c 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealEventPublisher.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealEventPublisher.kt @@ -187,4 +187,8 @@ internal class RealEventPublisher( override fun onUnknownEventNode(id: Id, tag: EventTag) { listener.onUnknownEventNode(app, id, tag) } + + override fun onUncaughtException(exception: Throwable) { + listener.uncaughtException(app, exception) + } } 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 b9184c404e..96e6151109 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,6 @@ 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.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler @@ -52,7 +51,7 @@ public class TreehouseApp private constructor( private val codeHost = ZiplineCodeHost() public val dispatchers: TreehouseDispatchers = - TreehouseDispatchersWithExceptionHandler(factory.dispatchers, codeHost.exceptionHandler) + TreehouseDispatchersWithExceptionHandler(factory.dispatchers, codeHost.asExceptionHandler()) private val eventPublisher = RealEventPublisher(factory.eventListener, this) @@ -205,22 +204,6 @@ public class TreehouseApp private constructor( override var session: ZiplineCodeSession? = null - /** Propagates exceptions on the Zipline dispatcher to the listeners. */ - val exceptionHandler = object : CoroutineExceptionHandler { - override val key: CoroutineContext.Key<*> - get() = CoroutineExceptionHandler.Key - - override fun handleException(context: CoroutineContext, exception: Throwable) { - appScope.launch(dispatchers.ui) { - for (listener in listeners) { - listener.uncaughtException(exception) - } - codeHost.session?.cancel() - codeHost.session = null - } - } - } - override fun newServiceScope(): CodeHost.ServiceScope { val ziplineScope = ZiplineScope() @@ -245,12 +228,25 @@ public class TreehouseApp private constructor( listeners -= listener } + override fun handleUncaughtException(exception: Throwable) { + appScope.launch(dispatchers.ui) { + for (listener in listeners) { + listener.uncaughtException(exception) + } + codeHost.session?.cancel() + codeHost.session = null + } + + eventPublisher.onUncaughtException(exception) + } + fun onCodeChanged(zipline: Zipline, appService: A) { val sessionScope = CoroutineScope(SupervisorJob(appScope.coroutineContext.job)) sessionScope.launch(dispatchers.ui) { val previous = session val next = ZiplineCodeSession( + codeHost = this@ZiplineCodeHost, dispatchers = dispatchers, eventPublisher = eventPublisher, appScope = appScope, diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt index d81b2c4357..a10df125d2 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/ZiplineCodeSession.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.Json internal class ZiplineCodeSession( + private val codeHost: CodeHost<*>, private val dispatchers: TreehouseDispatchers, private val eventPublisher: EventPublisher, private val appScope: CoroutineScope, @@ -44,7 +45,7 @@ internal class ZiplineCodeSession( frameClock.start(sessionScope, dispatchers) sessionScope.launch(dispatchers.zipline) { val appLifecycle = appService.withScope(ziplineScope).appLifecycle - val host = RealAppLifecycleHost(appLifecycle, eventPublisher, frameClock) + val host = RealAppLifecycleHost(codeHost, appLifecycle, eventPublisher, frameClock) appLifecycle.start(host) } } @@ -60,6 +61,7 @@ internal class ZiplineCodeSession( /** Platform features to the guest application. */ private class RealAppLifecycleHost( + val codeHost: CodeHost<*>, val appLifecycle: AppLifecycle, val eventPublisher: EventPublisher, val frameClock: FrameClock, @@ -81,4 +83,8 @@ private class RealAppLifecycleHost( ) { eventPublisher.onUnknownEventNode(id, tag) } + + override fun handleUncaughtException(exception: Throwable) { + codeHost.handleUncaughtException(exception) + } } 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 5589bd67b2..23b07c5f5b 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 @@ -55,7 +55,7 @@ internal class FakeCodeHost : CodeHost { } } - fun triggerException(exception: Throwable) { + override fun handleUncaughtException(exception: Throwable) { for (listener in listeners) { listener.uncaughtException(exception) } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeEventPublisher.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeEventPublisher.kt index 370f2ed6bd..9c30fcaa2d 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeEventPublisher.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeEventPublisher.kt @@ -37,4 +37,7 @@ class FakeEventPublisher : EventPublisher { override fun onUnknownEventNode(id: Id, tag: EventTag) { } + + override fun onUncaughtException(exception: Throwable) { + } } 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 c05ae8f6b6..20579a584e 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 @@ -304,7 +304,7 @@ class TreehouseAppContentTest { content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[0].start()") - codeHost.triggerException(Exception("boom!")) + codeHost.handleUncaughtException(Exception("boom!")) eventLog.takeEventsInAnyOrder( "codeSessionA.app.uis[0].close()", "codeListener.onUncaughtException(view1, kotlin.Exception: boom!)", @@ -321,7 +321,7 @@ class TreehouseAppContentTest { codeHost.session = FakeCodeSession("codeSessionA", eventLog) eventLog.takeEvent("codeSessionA.start()") - codeHost.triggerException(Exception("boom!")) + codeHost.handleUncaughtException(Exception("boom!")) eventLog.takeEvent("codeSessionA.cancel()") val view1 = FakeTreehouseView("view1") @@ -347,7 +347,7 @@ class TreehouseAppContentTest { content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[0].start()") - codeHost.triggerException(Exception("boom!")) + codeHost.handleUncaughtException(Exception("boom!")) eventLog.takeEventsInAnyOrder( "codeSessionA.app.uis[0].close()", "codeListener.onUncaughtException(view1, kotlin.Exception: boom!)", @@ -376,7 +376,7 @@ class TreehouseAppContentTest { content.preload(FakeOnBackPressedDispatcher(), uiConfiguration) eventLog.takeEvent("codeSessionA.app.uis[0].start()") - codeHost.triggerException(Exception("boom!")) + codeHost.handleUncaughtException(Exception("boom!")) eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.cancel()") diff --git a/redwood-treehouse/api/zipline-api.toml b/redwood-treehouse/api/zipline-api.toml index 3e73f63228..8e26976876 100644 --- a/redwood-treehouse/api/zipline-api.toml +++ b/redwood-treehouse/api/zipline-api.toml @@ -17,6 +17,9 @@ functions = [ # fun close(): kotlin.Unit "moYx+T3e", + # fun handleUncaughtException(kotlin.Throwable): kotlin.Unit + "Hls+uhG7", + # fun onUnknownEvent(app.cash.redwood.protocol.WidgetTag, app.cash.redwood.protocol.EventTag): kotlin.Unit "jmKreoSS", diff --git a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppLifecycle.kt b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppLifecycle.kt index 2072793106..bc95c8f2d1 100644 --- a/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppLifecycle.kt +++ b/redwood-treehouse/src/commonMain/kotlin/app/cash/redwood/treehouse/AppLifecycle.kt @@ -30,8 +30,16 @@ public interface AppLifecycle : ZiplineService { public interface Host : ZiplineService { public fun requestFrame() + /** Notify the host that an event was unrecognized and will be ignored. */ public fun onUnknownEvent(widgetTag: WidgetTag, tag: EventTag) + /** + * Notify the host that an event was received for a node that no longer exists. + * That event will be ignored. + */ public fun onUnknownEventNode(id: Id, tag: EventTag) + + /** Handle an uncaught exception. The app is now in an undefined state and must be stopped. */ + public fun handleUncaughtException(exception: Throwable) } } diff --git a/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt b/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt index 5f1a5da19b..99407b94df 100644 --- a/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt +++ b/samples/emoji-search/presenter/src/commonMain/kotlin/com/example/redwood/emojisearch/presenter/EmojiSearch.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.SaverScope import androidx.compose.runtime.saveable.rememberSaveable @@ -44,6 +45,7 @@ import com.example.redwood.emojisearch.compose.Image import com.example.redwood.emojisearch.compose.Text import com.example.redwood.emojisearch.compose.TextInput import example.values.TextFieldState +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json data class EmojiImage( @@ -85,6 +87,7 @@ private fun LazyColumn( httpClient: HttpClient, navigator: Navigator, ) { + val scope = rememberCoroutineScope() val allEmojis = remember { mutableStateListOf() } // Simple counter that allows us to trigger refreshes by simple incrementing the value @@ -139,9 +142,15 @@ private fun LazyColumn( hint = "Search", onChange = { textFieldState -> // Make it easy to trigger a crash to manually test exception handling! - if (textFieldState.text == "crash") { - throw RuntimeException("boom!") + when (textFieldState.text) { + "crash" -> throw RuntimeException("boom!") + "async" -> { + scope.launch { + throw RuntimeException("boom!") + } + } } + searchTerm = textFieldState }, )