Skip to content

Commit

Permalink
Promote more lifecycle to ZiplineCodeSession
Browse files Browse the repository at this point in the history
Previously TreehouseApp was tracking exceptions on behalf
of a class that could do it better.

This introduces listeners at the CodeSession level in
addition to the existing listeners at the CodeHost level.
  • Loading branch information
squarejesse committed Oct 30, 2023
1 parent 2775530 commit 2d69dce
Show file tree
Hide file tree
Showing 10 changed files with 284 additions and 206 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,49 +15,18 @@
*/
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<A : AppService> {
val stateStore: StateStore

/** Only accessed on [TreehouseDispatchers.ui]. */
val session: CodeSession<A>?

fun newServiceScope(): ServiceScope<A>

/** Cancels the current code and propagates [exception] to all listeners. */
fun handleUncaughtException(exception: Throwable)

fun addListener(listener: Listener<A>)

fun removeListener(listener: Listener<A>)

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

/**
* Tracks all of the services created to produce a UI, and offers a single mechanism to close
* them all. Note that closing this does not close the app services it was applied to.
*/
interface ServiceScope<A : AppService> {
/**
* Returns a new instance that forwards calls to [appService] and keeps track of returned
* instances so they may be closed.
*/
fun apply(appService: A): A
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
*/
package app.cash.redwood.treehouse

import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.Json

/** The host state for a single code load. We get a new session each time we get new code. */
Expand All @@ -23,7 +26,44 @@ internal interface CodeSession<A : AppService> {

val json: Json

fun start()
fun start(sessionScope: CoroutineScope)

fun addListener(listener: Listener<A>)

fun removeListener(listener: Listener<A>)

fun newServiceScope(): ServiceScope<A>

/** Propagates [exception] to all listeners and cancels this session. */
fun handleUncaughtException(exception: Throwable)

fun cancel()

/**
* Tracks all of the services created to produce a UI, and offers a single mechanism to close
* them all. Note that closing this does not close the app services it was applied to.
*/
interface ServiceScope<A : AppService> {
/**
* Returns a new instance that forwards calls to [appService] and keeps track of returned
* instances so they may be closed.
*/
fun apply(appService: A): A
fun close()
}

interface Listener<A : AppService> {
fun onUncaughtException(codeSession: CodeSession<A>, exception: Throwable)
fun onCancel(codeSession: CodeSession<A>)
}
}

internal val CodeSession<*>.coroutineExceptionHandler: CoroutineExceptionHandler
get() = object : CoroutineExceptionHandler {
override val key: CoroutineContext.Key<*>
get() = CoroutineExceptionHandler.Key

override fun handleException(context: CoroutineContext, exception: Throwable) {
handleUncaughtException(exception)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,12 @@
package app.cash.redwood.treehouse

import app.cash.zipline.Zipline
import app.cash.zipline.ZiplineScope
import app.cash.zipline.loader.LoadResult
import app.cash.zipline.loader.ManifestVerifier
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.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 @@ -50,8 +46,7 @@ public class TreehouseApp<A : AppService> private constructor(
) {
private val codeHost = ZiplineCodeHost<A>()

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

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

Expand Down Expand Up @@ -123,14 +118,10 @@ 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 = dispatcher,
dispatcher = dispatchers.zipline,
manifestVerifier = factory.manifestVerifier,
httpClient = factory.httpClient,
eventListener = eventPublisher.ziplineEventListener,
Expand Down Expand Up @@ -179,20 +170,14 @@ public class TreehouseApp<A : AppService> private constructor(
closed = true
appScope.launch(dispatchers.ui) {
val session = codeHost.session ?: return@launch
session.removeListener(codeHost)
session.cancel()
codeHost.session = null
}
eventPublisher.appCanceled()
}

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> {
private inner class ZiplineCodeHost<A : AppService> : CodeHost<A>, CodeSession.Listener<A> {
/**
* Contents that this app is currently responsible for.
*
Expand All @@ -204,20 +189,6 @@ public class TreehouseApp<A : AppService> private constructor(

override var session: ZiplineCodeSession<A>? = null

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

return object : CodeHost.ServiceScope<A> {
override fun apply(appService: A): A {
return appService.withScope(ziplineScope)
}

override fun close() {
ziplineScope.close()
}
}
}

override fun addListener(listener: CodeHost.Listener<A>) {
dispatchers.checkUi()
listeners += listener
Expand All @@ -228,47 +199,40 @@ public class TreehouseApp<A : AppService> 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
}
override fun onUncaughtException(codeSession: CodeSession<A>, exception: Throwable) {
}

eventPublisher.onUncaughtException(exception)
override fun onCancel(codeSession: CodeSession<A>) {
check(codeSession == this.session)
this.session = null
}

fun onCodeChanged(zipline: Zipline, appService: A) {
val sessionScope = CoroutineScope(SupervisorJob(appScope.coroutineContext.job))
val next = ZiplineCodeSession(
dispatchers = dispatchers,
eventPublisher = eventPublisher,
appScope = appScope,
frameClock = factory.frameClock,
appService = appService,
zipline = zipline,
)

val sessionScope = CoroutineScope(
SupervisorJob(appScope.coroutineContext.job) + next.coroutineExceptionHandler,
)

sessionScope.launch(dispatchers.ui) {
val previous = session
previous?.removeListener(this@ZiplineCodeHost)
previous?.cancel()

val next = ZiplineCodeSession(
codeHost = this@ZiplineCodeHost,
dispatchers = dispatchers,
eventPublisher = eventPublisher,
appScope = appScope,
frameClock = factory.frameClock,
sessionScope = sessionScope,
appService = appService,
zipline = zipline,
)

next.start()
session = next
next.addListener(this@ZiplineCodeHost)
next.start(sessionScope)

for (listener in listeners) {
listener.codeSessionChanged(next)
}

if (previous != null) {
sessionScope.launch(dispatchers.zipline) {
previous.cancel()
}
}

session = next
}
}
}
Expand Down
Loading

0 comments on commit 2d69dce

Please sign in to comment.