Skip to content

Commit

Permalink
WIP: APIs for uncaught exceptions
Browse files Browse the repository at this point in the history
  • Loading branch information
squarejesse committed Oct 29, 2023
1 parent e3659c2 commit 19b61bc
Show file tree
Hide file tree
Showing 9 changed files with 126 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
package app.cash.redwood.treehouse

/** Manages loading and hot-reloading a series of code sessions. */
internal interface CodeHost<A : AppService> {
internal interface CodeHost<A : AppService> : CodeListener.CrashResponse {
val stateStore: StateStore

/** Only accessed on [TreehouseDispatchers.ui]. */
Expand All @@ -29,6 +29,7 @@ internal interface CodeHost<A : AppService> {
fun removeListener(listener: Listener<A>)

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,22 +48,17 @@ 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 = factory.dispatchers.withExceptionHandler(codeHost)

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 @@ -128,7 +125,7 @@ public class TreehouseApp<A : AppService> private constructor(
private fun ziplineFlow(): Flow<LoadResult> {
// 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,
Expand Down Expand Up @@ -183,22 +180,30 @@ 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
/** 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<A : AppService> : CodeHost<A>, CoroutineExceptionHandler {
/**
* 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

override val key: CoroutineContext.Key<*>
get() = CoroutineExceptionHandler.Key

override fun newServiceScope(): CodeHost.ServiceScope<A> {
val ziplineScope = ZiplineScope()

Expand All @@ -213,6 +218,18 @@ public class TreehouseApp<A : AppService> 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<A>) {
dispatchers.checkUi()
listeners += listener
Expand All @@ -232,7 +249,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 @@ -182,6 +182,16 @@ internal class TreehouseAppContent<A : AppService>(
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<A>) {
dispatchers.checkUi()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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 @@ -15,7 +15,9 @@
*/
package app.cash.redwood.treehouse

internal class FakeCodeHost : CodeHost<FakeAppService> {
internal class FakeCodeHost(
private val eventLog: EventLog,
) : CodeHost<FakeAppService> {
override val stateStore = MemoryStateStore()

override var session: CodeSession<FakeAppService>? = null
Expand Down Expand Up @@ -54,6 +56,16 @@ internal class FakeCodeHost : CodeHost<FakeAppService> {
}
}

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

override fun restart() {
eventLog += "restart()"
}

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,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)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<FakeAppService> {
return TreehouseAppContent(
codeHost = codeHost,
Expand Down

0 comments on commit 19b61bc

Please sign in to comment.