Skip to content

Commit

Permalink
CodeListener.onUncaughtException
Browse files Browse the repository at this point in the history
This new API is called on host views when the guest code fails
with an uncaught exception.

This wires in a CoroutineExceptionHandler in the host code
that cancels the current Zipline instance and updates all
UIs its currently serving, if any.

There is not yet any support for exceptions that occur in
async code, or any mechanism to restart the Zipline instance
after a crash.
  • Loading branch information
squarejesse committed Oct 29, 2023
1 parent e3659c2 commit 7a96d43
Show file tree
Hide file tree
Showing 13 changed files with 339 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ internal interface CodeHost<A : AppService> {

interface Listener<A : AppService> {
fun codeSessionChanged(next: CodeSession<A>)
fun uncaughtException(exception: Throwable)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,23 @@ 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.
*
* 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.
*
* 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,
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ 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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
Expand All @@ -46,22 +49,18 @@ public class TreehouseApp<A : AppService> private constructor(
private val appScope: CoroutineScope,
public val spec: Spec<A>,
) {
public val dispatchers: TreehouseDispatchers = factory.dispatchers
private val codeHost = ZiplineCodeHost<A>()

public val dispatchers: TreehouseDispatchers =
TreehouseDispatchersWithExceptionHandler(factory.dispatchers, codeHost.exceptionHandler)

private val eventPublisher = RealEventPublisher(factory.eventListener, this)

private var started = false

/** Only accessed on [TreehouseDispatchers.zipline]. */
private var closed = false

private val codeHost = ZiplineCodeHost<A>(
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.
Expand Down Expand Up @@ -125,10 +124,14 @@ public class TreehouseApp<A : AppService> private constructor(
* Continuously polls for updated code, and emits a new [LoadResult] instance when new code is
* found.
*/
@OptIn(ExperimentalStdlibApi::class)
private fun ziplineFlow(): Flow<LoadResult> {
val dispatcher = dispatchers.zipline[CoroutineDispatcher.Key]
?: error("expected TreehouseDispatchers.zipline to include a CoroutineDispatcher")

// Loads applications from the network only. The cache is neither read nor written.
var loader = ZiplineLoader(
dispatcher = dispatchers.zipline,
dispatcher = dispatcher,
manifestVerifier = factory.manifestVerifier,
httpClient = factory.httpClient,
eventListener = eventPublisher.ziplineEventListener,
Expand Down Expand Up @@ -183,22 +186,41 @@ public class TreehouseApp<A : AppService> private constructor(
eventPublisher.appCanceled()
}

private class ZiplineCodeHost<A : AppService>(
private val appScope: CoroutineScope,
private val dispatchers: TreehouseDispatchers,
private val eventPublisher: EventPublisher,
private val frameClock: FrameClock,
override val stateStore: StateStore,
) : CodeHost<A> {
override var session: ZiplineCodeSession<A>? = null
private class TreehouseDispatchersWithExceptionHandler(
val delegate: TreehouseDispatchers,
exceptionHandler: CoroutineExceptionHandler,
) : TreehouseDispatchers by delegate {
override val zipline = delegate.zipline + exceptionHandler
}

private inner class ZiplineCodeHost<A : AppService> : CodeHost<A> {
/**
* Contents that this app is currently responsible for.
*
* Only accessed on [TreehouseDispatchers.ui].
*/
private val listeners = mutableListOf<CodeHost.Listener<A>>()

override val stateStore: StateStore = factory.stateStore

override var session: ZiplineCodeSession<A>? = 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<A> {
val ziplineScope = ZiplineScope()

Expand Down Expand Up @@ -232,7 +254,7 @@ public class TreehouseApp<A : AppService> private constructor(
dispatchers = dispatchers,
eventPublisher = eventPublisher,
appScope = appScope,
frameClock = frameClock,
frameClock = factory.frameClock,
sessionScope = sessionScope,
appService = appService,
zipline = zipline,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,33 @@ internal class TreehouseAppContent<A : AppService>(
stateFlow.value = State(viewState, nextCodeState)
}

/**
* If the code crashes, show an error on the UI and cancel the UI binding. This sets the code
* state back to idle.
*/
override fun uncaughtException(exception: Throwable) {
dispatchers.checkUi()

val previousState = stateFlow.value
val viewState = previousState.viewState
val previousCodeState = previousState.codeState

// If there wasn't code running, there's nothing to do.
if (previousCodeState !is CodeState.Running) return

// Cancel the UI binding to the crashed code.
previousCodeState.viewContentCodeBinding.cancel()

// If there's a UI, give it the error to display.
val view = (viewState as? ViewState.Bound)?.view
if (view != null) {
codeListener.onUncaughtException(view, exception)
}

val nextCodeState = CodeState.Idle<A>()
stateFlow.value = State(viewState, nextCodeState)
}

/** This function may only be invoked on [TreehouseDispatchers.ui]. */
private fun startViewCodeContentBinding(
codeSession: CodeSession<A>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,24 @@
*/
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
/** Must contain a non-null [kotlinx.coroutines.CoroutineDispatcher]. */
public val ui: CoroutineContext

/** Must contain a non-null [kotlinx.coroutines.CoroutineDispatcher]. */
public val zipline: CoroutineContext

/**
* Confirm that this is being called on the UI thread.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package app.cash.redwood.treehouse

import assertk.assertThat
import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.isEqualTo
import kotlinx.coroutines.channels.Channel

Expand All @@ -35,6 +36,18 @@ class EventLog {
assertThat(takeEvent()).isEqualTo(event)
}

/**
* Take all the events in [events], in any order. Use this when events published are dependent on
* dispatch order.
*/
suspend fun takeEventsInAnyOrder(vararg events: String) {
val actual = mutableListOf<String>()
while (actual.size < events.size) {
actual += takeEvent()
}
assertThat(actual).containsExactlyInAnyOrder(*events)
}

fun assertNoEvents() {
val received = events.tryReceive()
check(received.isFailure) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ internal class FakeCodeHost : CodeHost<FakeAppService> {

override var session: CodeSession<FakeAppService>? = null
set(value) {
require(value != null)

val previous = field
previous?.cancel()

value.start()
for (listener in listeners) {
listener.codeSessionChanged(value)
if (value != null) {
value.start()
for (listener in listeners) {
listener.codeSessionChanged(value)
}
}

field = value
}

Expand All @@ -54,6 +55,13 @@ internal class FakeCodeHost : CodeHost<FakeAppService> {
}
}

fun triggerException(exception: Throwable) {
for (listener in listeners) {
listener.uncaughtException(exception)
}
session = null
}

override fun addListener(listener: CodeHost.Listener<FakeAppService>) {
listeners += listener
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,25 @@ 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,
) {
// Canonicalize "java.lang.Exception(boom!)" to "kotlin.Exception(boom!)".
val exceptionString = e.toString().replace("java.lang.", "kotlin.")
eventLog += "codeListener.onUncaughtException($view, $exceptionString)"
}
}
Loading

0 comments on commit 7a96d43

Please sign in to comment.