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
},
)