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 525566b365..4da11616cf 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,9 +15,6 @@ */ 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 @@ -25,39 +22,11 @@ internal interface CodeHost { /** Only accessed on [TreehouseDispatchers.ui]. */ val session: CodeSession? - 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) interface Listener { fun codeSessionChanged(next: CodeSession) - 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 { - /** - * 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) } } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt index 5193d5a139..9d7f374b32 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/CodeSession.kt @@ -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. */ @@ -23,7 +26,44 @@ internal interface CodeSession { val json: Json - fun start() + fun start(sessionScope: CoroutineScope) + + fun addListener(listener: Listener) + + fun removeListener(listener: Listener) + + fun newServiceScope(): ServiceScope + + /** 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 { + /** + * 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 { + fun onUncaughtException(codeSession: CodeSession, exception: Throwable) + fun onCancel(codeSession: CodeSession) + } } + +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) + } + } 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 96e6151109..742387286d 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 @@ -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 @@ -50,8 +46,7 @@ public class TreehouseApp private constructor( ) { private val codeHost = ZiplineCodeHost() - public val dispatchers: TreehouseDispatchers = - TreehouseDispatchersWithExceptionHandler(factory.dispatchers, codeHost.asExceptionHandler()) + public val dispatchers: TreehouseDispatchers = factory.dispatchers private val eventPublisher = RealEventPublisher(factory.eventListener, this) @@ -123,14 +118,10 @@ public class TreehouseApp 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 { - 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, @@ -179,20 +170,14 @@ public class TreehouseApp 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 : CodeHost { + private inner class ZiplineCodeHost : CodeHost, CodeSession.Listener { /** * Contents that this app is currently responsible for. * @@ -204,20 +189,6 @@ public class TreehouseApp private constructor( override var session: ZiplineCodeSession? = null - override fun newServiceScope(): CodeHost.ServiceScope { - val ziplineScope = ZiplineScope() - - return object : CodeHost.ServiceScope { - override fun apply(appService: A): A { - return appService.withScope(ziplineScope) - } - - override fun close() { - ziplineScope.close() - } - } - } - override fun addListener(listener: CodeHost.Listener) { dispatchers.checkUi() listeners += listener @@ -228,47 +199,40 @@ 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 - } + override fun onUncaughtException(codeSession: CodeSession, exception: Throwable) { + } - eventPublisher.onUncaughtException(exception) + override fun onCancel(codeSession: CodeSession) { + 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 } } } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt index d1a8ef1096..eaa242be63 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseAppContent.kt @@ -35,7 +35,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.job import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json private class State( val viewState: ViewState, @@ -75,7 +74,7 @@ internal class TreehouseAppContent( private val eventPublisher: EventPublisher, private val codeListener: CodeListener, private val source: TreehouseContentSource, -) : Content, CodeHost.Listener { +) : Content, CodeHost.Listener, CodeSession.Listener { private val stateFlow = MutableStateFlow>(State(ViewState.None, CodeState.Idle())) override fun preload( @@ -176,7 +175,9 @@ internal class TreehouseAppContent( // Cancel the code if necessary. codeHost.removeListener(this) if (previousState.codeState is CodeState.Running) { - previousState.codeState.viewContentCodeBinding.cancel() + val binding = previousState.codeState.viewContentCodeBinding + binding.cancel() + binding.codeSession.removeListener(this) } stateFlow.value = State(nextViewState, nextCodeState) @@ -217,7 +218,9 @@ internal class TreehouseAppContent( // If we replaced an old binding, cancel that old binding. if (previousCodeState is CodeState.Running) { - previousCodeState.viewContentCodeBinding.cancel() + val binding = previousCodeState.viewContentCodeBinding + binding.cancel() + binding.codeSession.removeListener(this) } stateFlow.value = State(viewState, nextCodeState) @@ -227,18 +230,20 @@ internal class TreehouseAppContent( * 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) { + override fun onUncaughtException(codeSession: CodeSession, 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 + // This listener should only fire if we're actively running code. + require(previousCodeState is CodeState.Running) // Cancel the UI binding to the crashed code. - previousCodeState.viewContentCodeBinding.cancel() + val binding = previousCodeState.viewContentCodeBinding + binding.cancel() + binding.codeSession.removeListener(this) // If there's a UI, give it the error to display. val view = (viewState as? ViewState.Bound)?.view @@ -250,6 +255,9 @@ internal class TreehouseAppContent( stateFlow.value = State(viewState, nextCodeState) } + override fun onCancel(codeSession: CodeSession) { + } + /** This function may only be invoked on [TreehouseDispatchers.ui]. */ private fun startViewCodeContentBinding( codeSession: CodeSession, @@ -258,21 +266,22 @@ internal class TreehouseAppContent( firstUiConfiguration: StateFlow, ): ViewContentCodeBinding { dispatchers.checkUi() + codeSession.addListener(this) return ViewContentCodeBinding( - codeHost = codeHost, + stateStore = codeHost.stateStore, dispatchers = dispatchers, appScope = appScope, eventPublisher = eventPublisher, contentSource = source, codeListener = codeListener, stateFlow = stateFlow, + codeSession = codeSession, isInitialLaunch = isInitialLaunch, - json = codeSession.json, onBackPressedDispatcher = onBackPressedDispatcher, firstUiConfiguration = firstUiConfiguration, ).apply { - start(codeSession) + start() } } } @@ -290,21 +299,23 @@ internal class TreehouseAppContent( * binding. */ private class ViewContentCodeBinding( - val codeHost: CodeHost, + val stateStore: StateStore, val dispatchers: TreehouseDispatchers, val appScope: CoroutineScope, val eventPublisher: EventPublisher, val contentSource: TreehouseContentSource, val codeListener: CodeListener, val stateFlow: MutableStateFlow>, + val codeSession: CodeSession, private val isInitialLaunch: Boolean, - private val json: Json, private val onBackPressedDispatcher: OnBackPressedDispatcher, firstUiConfiguration: StateFlow, ) : EventSink, ChangesSinkService, TreehouseView.SaveCallback, ZiplineTreehouseUi.Host { private val uiConfigurationFlow = SequentialStateFlow(firstUiConfiguration) - private val bindingScope = CoroutineScope(SupervisorJob(appScope.coroutineContext.job)) + private val bindingScope = CoroutineScope( + SupervisorJob(appScope.coroutineContext.job) + codeSession.coroutineExceptionHandler, + ) /** Only accessed on [TreehouseDispatchers.ui]. Null before [initView] and after [cancel]. */ private var viewOrNull: TreehouseView<*>? = null @@ -313,7 +324,7 @@ private class ViewContentCodeBinding( private var bridgeOrNull: ProtocolBridge<*>? = null /** Only accessed on [TreehouseDispatchers.zipline]. */ - private val serviceScope = codeHost.newServiceScope() + private val serviceScope = codeSession.newServiceScope() /** Only accessed on [TreehouseDispatchers.zipline]. Null after [cancel]. */ private var treehouseUiOrNull: ZiplineTreehouseUi? = null @@ -351,7 +362,7 @@ private class ViewContentCodeBinding( bridgeOrNull = ProtocolBridge( container = view.children as Widget.Children, factory = view.widgetSystem.widgetFactory( - json = json, + json = codeSession.json, protocolMismatchHandler = eventPublisher.widgetProtocolMismatchHandler, ) as ProtocolNodeFactory, eventSink = this, @@ -416,13 +427,13 @@ private class ViewContentCodeBinding( bridge.sendChanges(changes) } - fun start(session: CodeSession) { + fun start() { bindingScope.launch(dispatchers.zipline) { - val scopedAppService = serviceScope.apply(session.appService) + val scopedAppService = serviceScope.apply(codeSession.appService) val treehouseUi = contentSource.get(scopedAppService) treehouseUiOrNull = treehouseUi stateSnapshot = viewOrNull?.stateSnapshotId?.let { - codeHost.stateStore.get(it.value.orEmpty()) + stateStore.get(it.value.orEmpty()) } try { treehouseUi.start(this@ViewContentCodeBinding) @@ -464,9 +475,9 @@ private class ViewContentCodeBinding( } override fun performSave(id: String) { - appScope.launch(dispatchers.zipline) { + bindingScope.launch(dispatchers.zipline) { val state = treehouseUiOrNull?.snapshotState() ?: return@launch - codeHost.stateStore.put(id, state) + stateStore.put(id, state) } } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt index 3fdf2753e3..caa7d33f4b 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehouseDispatchers.kt @@ -15,8 +15,8 @@ */ 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: @@ -28,11 +28,9 @@ import kotlin.native.ObjCName */ @ObjCName("TreehouseDispatchers", exact = true) public interface TreehouseDispatchers { - /** Must contain a non-null [kotlinx.coroutines.CoroutineDispatcher]. */ - public val ui: CoroutineContext + public val ui: CoroutineDispatcher - /** Must contain a non-null [kotlinx.coroutines.CoroutineDispatcher]. */ - public val zipline: CoroutineContext + public val zipline: CoroutineDispatcher /** * Confirm that this is being called on the UI thread. 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 a10df125d2..950f672270 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 @@ -18,6 +18,7 @@ package app.cash.redwood.treehouse import app.cash.redwood.protocol.EventTag import app.cash.redwood.protocol.Id import app.cash.redwood.protocol.WidgetTag +import app.cash.redwood.treehouse.CodeSession.Listener import app.cash.zipline.Zipline import app.cash.zipline.ZiplineScope import app.cash.zipline.withScope @@ -27,45 +28,68 @@ 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, private val frameClock: FrameClock, - private val sessionScope: CoroutineScope, override val appService: A, val zipline: Zipline, -) : CodeSession { +) : CodeSession, AppLifecycle.Host { + private val listeners = mutableListOf>() private val ziplineScope = ZiplineScope() override val json: Json get() = zipline.json - override fun start() { - frameClock.start(sessionScope, dispatchers) + /** Only accessed on [TreehouseDispatchers.zipline]. */ + private lateinit var sessionScope: CoroutineScope + + /** Only accessed on [TreehouseDispatchers.zipline]. */ + private lateinit var appLifecycle: AppLifecycle + + private var canceled = false + + override fun start(sessionScope: CoroutineScope) { + dispatchers.checkUi() sessionScope.launch(dispatchers.zipline) { - val appLifecycle = appService.withScope(ziplineScope).appLifecycle - val host = RealAppLifecycleHost(codeHost, appLifecycle, eventPublisher, frameClock) - appLifecycle.start(host) + this@ZiplineCodeSession.sessionScope = sessionScope + + frameClock.start(sessionScope, dispatchers) + + val service = appService.withScope(ziplineScope).appLifecycle + appLifecycle = service + service.start(this@ZiplineCodeSession) } } + override fun addListener(listener: Listener) { + dispatchers.checkUi() + listeners += listener + } + + override fun removeListener(listener: Listener) { + dispatchers.checkUi() + listeners -= listener + } + override fun cancel() { + if (canceled) return + canceled = true + + dispatchers.checkUi() + + val listenersArray = listeners.toTypedArray() // onCancel mutates. + for (listener in listenersArray) { + listener.onCancel(this) + } + appScope.launch(dispatchers.zipline) { sessionScope.cancel() ziplineScope.close() zipline.close() } } -} -/** Platform features to the guest application. */ -private class RealAppLifecycleHost( - val codeHost: CodeHost<*>, - val appLifecycle: AppLifecycle, - val eventPublisher: EventPublisher, - val frameClock: FrameClock, -) : AppLifecycle.Host { override fun requestFrame() { frameClock.requestFrame(appLifecycle) } @@ -85,6 +109,28 @@ private class RealAppLifecycleHost( } override fun handleUncaughtException(exception: Throwable) { - codeHost.handleUncaughtException(exception) + appScope.launch(dispatchers.ui) { + val listenersArray = listeners.toTypedArray() // onUncaughtException mutates. + for (listener in listenersArray) { + listener.onUncaughtException(this@ZiplineCodeSession, exception) + } + this@ZiplineCodeSession.cancel() + } + + eventPublisher.onUncaughtException(exception) + } + + override fun newServiceScope(): CodeSession.ServiceScope { + val ziplineScope = ZiplineScope() + + return object : CodeSession.ServiceScope { + override fun apply(appService: A): A { + return appService.withScope(ziplineScope) + } + + override fun close() { + ziplineScope.close() + } + } } } 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 23b07c5f5b..7cf875a1b9 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 @@ -15,58 +15,60 @@ */ package app.cash.redwood.treehouse -internal class FakeCodeHost : CodeHost { +import app.cash.redwood.treehouse.CodeHost.Listener +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineScope + +internal class FakeCodeHost( + private val eventLog: EventLog, +) : CodeHost { override val stateStore = MemoryStateStore() + private val codeSessionListener = object : CodeSession.Listener { + override fun onUncaughtException( + codeSession: CodeSession, + exception: Throwable, + ) { + } + + override fun onCancel( + codeSession: CodeSession, + ) { + check(codeSession == this@FakeCodeHost.session) + this@FakeCodeHost.session = null + } + } + override var session: CodeSession? = null set(value) { val previous = field + previous?.removeListener(codeSessionListener) previous?.cancel() if (value != null) { - value.start() + value.start(CoroutineScope(EmptyCoroutineContext)) for (listener in listeners) { listener.codeSessionChanged(value) } } + value?.addListener(codeSessionListener) field = value } - private val listeners = mutableListOf>() + private val listeners = mutableListOf>() - override fun newServiceScope(): CodeHost.ServiceScope { - return object : CodeHost.ServiceScope { - val uisToClose = mutableListOf() - - override fun apply(appService: FakeAppService): FakeAppService { - return appService.withListener(object : FakeAppService.Listener { - override fun onNewUi(ui: ZiplineTreehouseUi) { - uisToClose += ui - } - }) - } - - override fun close() { - for (ui in uisToClose) { - ui.close() - } - } - } - } - - override fun handleUncaughtException(exception: Throwable) { - for (listener in listeners) { - listener.uncaughtException(exception) - } - session = null + fun startCodeSession(name: String): CodeSession { + val result = FakeCodeSession(eventLog, name) + session = result + return result } - override fun addListener(listener: CodeHost.Listener) { + override fun addListener(listener: Listener) { listeners += listener } - override fun removeListener(listener: CodeHost.Listener) { + override fun removeListener(listener: Listener) { listeners -= listener } } diff --git a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt index 2e158a1a31..fced2e625c 100644 --- a/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt +++ b/redwood-treehouse-host/src/commonTest/kotlin/app/cash/redwood/treehouse/FakeCodeSession.kt @@ -15,21 +15,72 @@ */ package app.cash.redwood.treehouse +import app.cash.redwood.treehouse.CodeSession.Listener +import app.cash.redwood.treehouse.CodeSession.ServiceScope +import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.json.Json internal class FakeCodeSession( - private val name: String, private val eventLog: EventLog, + private val name: String, ) : CodeSession { + private val listeners = mutableListOf>() + override val json = Json override val appService = FakeAppService("$name.app", eventLog) - override fun start() { + private var canceled = false + + override fun start(sessionScope: CoroutineScope) { eventLog += "$name.start()" } + override fun addListener(listener: Listener) { + listeners += listener + } + + override fun removeListener(listener: Listener) { + listeners -= listener + } + + override fun handleUncaughtException(exception: Throwable) { + val listenersArray = listeners.toTypedArray() // onUncaughtException mutates. + for (listener in listenersArray) { + listener.onUncaughtException(this, exception) + } + cancel() + } + + override fun newServiceScope(): ServiceScope { + return object : ServiceScope { + val uisToClose = mutableListOf() + + override fun apply(appService: FakeAppService): FakeAppService { + return appService.withListener(object : FakeAppService.Listener { + override fun onNewUi(ui: ZiplineTreehouseUi) { + uisToClose += ui + } + }) + } + + override fun close() { + for (ui in uisToClose) { + ui.close() + } + } + } + } + override fun cancel() { + if (canceled) return + canceled = true + + val listenersArray = listeners.toTypedArray() // onCancel mutates. + for (listener in listenersArray) { + listener.onCancel(this) + } + eventLog += "$name.cancel()" } } 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 20579a584e..376805eead 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 @@ -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) @@ -52,11 +52,11 @@ class TreehouseAppContentTest { content.bind(view1) eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") - codeHost.session!!.appService.uis.single().addWidget("hello") + codeSessionA.appService.uis.single().addWidget("hello") eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") assertThat(view1.children.single().value.label).isEqualTo("hello") @@ -71,12 +71,12 @@ class TreehouseAppContentTest { content.preload(FakeOnBackPressedDispatcher(), uiConfiguration) eventLog.assertNoEvents() - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") // Guest code can add widgets before a TreehouseView is bound! - codeHost.session!!.appService.uis.single().addWidget("hello") + codeSessionA.appService.uis.single().addWidget("hello") eventLog.assertNoEvents() val view1 = FakeTreehouseView("view1") @@ -91,7 +91,7 @@ class TreehouseAppContentTest { fun session_preload_bind_addWidget_unbind() = runTest { val content = treehouseAppContent() - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") content.preload(FakeOnBackPressedDispatcher(), uiConfiguration) @@ -101,7 +101,7 @@ class TreehouseAppContentTest { content.bind(view1) eventLog.assertNoEvents() - codeHost.session!!.appService.uis.single().addWidget("hello") + codeSessionA.appService.uis.single().addWidget("hello") eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") assertThat(view1.children.single().value.label).isEqualTo("hello") @@ -113,14 +113,14 @@ class TreehouseAppContentTest { fun session_bind_addWidget_unbind() = runTest { val content = treehouseAppContent() - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") val view1 = FakeTreehouseView("view1") content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[0].start()") - codeHost.session!!.appService.uis.single().addWidget("hello") + codeSessionA.appService.uis.single().addWidget("hello") eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") assertThat(view1.children.single().value.label).isEqualTo("hello") @@ -137,15 +137,13 @@ class TreehouseAppContentTest { content.bind(view1) eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") - val codeSessionA = FakeCodeSession("codeSessionA", eventLog) - codeHost.session = codeSessionA + val codeSessionA = codeHost.startCodeSession("codeSessionA") codeSessionA.appService.uis.single().addWidget("helloA") eventLog.takeEvent("codeSessionA.start()") eventLog.takeEvent("codeSessionA.app.uis[0].start()") eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") - val codeSessionB = FakeCodeSession("codeSessionB", eventLog) - codeHost.session = codeSessionB + val codeSessionB = codeHost.startCodeSession("codeSessionB") eventLog.takeEvent("codeSessionA.cancel()") eventLog.takeEvent("codeSessionB.start()") eventLog.takeEvent("codeSessionB.app.uis[0].start()") @@ -174,7 +172,7 @@ class TreehouseAppContentTest { eventLog.assertNoEvents() // Code that arrives after a preloaded UI unbinds doesn't do anything. - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") } @@ -190,7 +188,7 @@ class TreehouseAppContentTest { eventLog.assertNoEvents() // Code that arrives after a bound UI unbinds doesn't do anything. - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") } @@ -202,14 +200,14 @@ class TreehouseAppContentTest { fun session_bind_addWidget_unbind_bind_unbind() = runTest { val content = treehouseAppContent() - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") val view1 = FakeTreehouseView("view1") content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[0].start()") - codeHost.session!!.appService.uis.single().addWidget("helloA") + codeSessionA.appService.uis.single().addWidget("helloA") eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") assertThat(view1.children.single().value.label).isEqualTo("helloA") @@ -219,7 +217,7 @@ class TreehouseAppContentTest { content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[1].start()") - codeHost.session!!.appService.uis.last().addWidget("helloB") + codeSessionA.appService.uis.last().addWidget("helloB") eventLog.takeEvent("codeListener.onCodeLoaded(view1, initial = true)") assertThat(view1.children.single().value.label).isEqualTo("helloB") @@ -233,10 +231,10 @@ class TreehouseAppContentTest { val view1 = FakeTreehouseView("view1") content.bind(view1) - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.clear() - val backCancelable = codeHost.session!!.appService.uis.single().addBackHandler(true) + val backCancelable = codeSessionA.appService.uis.single().addBackHandler(true) view1.onBackPressedDispatcher.onBack() eventLog.takeEvent("codeSessionA.app.uis[0].onBackPressed()") @@ -257,10 +255,10 @@ class TreehouseAppContentTest { val view1 = FakeTreehouseView("view1") content.bind(view1) - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.clear() - val backCancelable = codeHost.session!!.appService.uis.single().addBackHandler(false) + val backCancelable = codeSessionA.appService.uis.single().addBackHandler(false) view1.onBackPressedDispatcher.onBack() eventLog.assertNoEvents() @@ -276,14 +274,12 @@ class TreehouseAppContentTest { val view1 = FakeTreehouseView("view1") content.bind(view1) - val codeSessionA = FakeCodeSession("codeSessionA", eventLog) - codeHost.session = codeSessionA + val codeSessionA = codeHost.startCodeSession("codeSessionA") codeSessionA.appService.uis.single().addBackHandler(true) assertThat(view1.onBackPressedDispatcher.callbacks).isNotEmpty() - val codeSessionB = FakeCodeSession("codeSessionB", eventLog) - codeHost.session = codeSessionB + codeHost.startCodeSession("codeSessionB") // When we close codeSessionA, its back handlers are released with it. assertThat(view1.onBackPressedDispatcher.callbacks).isEmpty() @@ -297,14 +293,14 @@ class TreehouseAppContentTest { fun session_bind_triggerException() = runTest { val content = treehouseAppContent() - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") val view1 = FakeTreehouseView("view1") content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[0].start()") - codeHost.handleUncaughtException(Exception("boom!")) + codeSessionA.handleUncaughtException(Exception("boom!")) eventLog.takeEventsInAnyOrder( "codeSessionA.app.uis[0].close()", "codeListener.onUncaughtException(view1, kotlin.Exception: boom!)", @@ -318,17 +314,17 @@ class TreehouseAppContentTest { fun triggerException_bind_session() = runTest { val content = treehouseAppContent() - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") - codeHost.handleUncaughtException(Exception("boom!")) + codeSessionA.handleUncaughtException(Exception("boom!")) eventLog.takeEvent("codeSessionA.cancel()") val view1 = FakeTreehouseView("view1") content.bind(view1) eventLog.takeEvent("codeListener.onInitialCodeLoading(view1)") - codeHost.session = FakeCodeSession("codeSessionB", eventLog) + codeHost.startCodeSession("codeSessionB") eventLog.takeEvent("codeSessionB.start()") eventLog.takeEvent("codeSessionB.app.uis[0].start()") @@ -340,21 +336,21 @@ class TreehouseAppContentTest { fun sessionA_bind_triggerException_sessionB() = runTest { val content = treehouseAppContent() - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") val view1 = FakeTreehouseView("view1") content.bind(view1) eventLog.takeEvent("codeSessionA.app.uis[0].start()") - codeHost.handleUncaughtException(Exception("boom!")) + codeSessionA.handleUncaughtException(Exception("boom!")) eventLog.takeEventsInAnyOrder( "codeSessionA.app.uis[0].close()", "codeListener.onUncaughtException(view1, kotlin.Exception: boom!)", "codeSessionA.cancel()", ) - codeHost.session = FakeCodeSession("codeSessionB", eventLog) + codeHost.startCodeSession("codeSessionB") eventLog.takeEvent("codeSessionB.start()") eventLog.takeEvent("codeSessionB.app.uis[0].start()") @@ -370,13 +366,13 @@ class TreehouseAppContentTest { fun sessionA_preload_triggerException_bind() = runTest { val content = treehouseAppContent() - codeHost.session = FakeCodeSession("codeSessionA", eventLog) + val codeSessionA = codeHost.startCodeSession("codeSessionA") eventLog.takeEvent("codeSessionA.start()") content.preload(FakeOnBackPressedDispatcher(), uiConfiguration) eventLog.takeEvent("codeSessionA.app.uis[0].start()") - codeHost.handleUncaughtException(Exception("boom!")) + codeSessionA.handleUncaughtException(Exception("boom!")) eventLog.takeEvent("codeSessionA.app.uis[0].close()") eventLog.takeEvent("codeSessionA.cancel()") 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 0542d2da96..657ffa77a4 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 @@ -27,6 +27,7 @@ public interface AppLifecycle : ZiplineService { public fun sendFrame(timeNanos: Long) + /** Platform features to the guest application. */ public interface Host : ZiplineService { public fun requestFrame()