From 0fb514288e7957eb150dd589db88d48f52c50e9d Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Tue, 16 Jul 2024 11:08:03 -0400 Subject: [PATCH] Use a different thread for each TreehouseApp This should prevent different apps from competing with each other for CPU resources, especially during the first few seconds of the host app's launch, when there's a bunch of CPU-bound work to do. --- CHANGELOG.md | 3 + .../api/android/redwood-treehouse-host.api | 1 - .../api/jvm/redwood-treehouse-host.api | 1 - .../api/redwood-treehouse-host.klib.api | 3 - .../treehouse/AndroidTreehouseDispatchers.kt | 14 ++++- .../treehouse/AndroidTreehousePlatform.kt | 3 + .../treehouse/treehouseAppFactoryAndroid.kt | 4 +- .../treehouse/FakeZiplineLoaderDispatcher.kt | 44 ++++++++++++++ .../app/cash/redwood/treehouse/LeaksTest.kt | 21 +++---- .../cash/redwood/treehouse/TreehouseTester.kt | 19 ++++++- .../redwood/treehouse/RealTreehouseApp.kt | 26 +++++++-- .../cash/redwood/treehouse/TreehouseApp.kt | 2 - .../redwood/treehouse/TreehouseDispatchers.kt | 8 ++- .../redwood/treehouse/TreehousePlatform.kt | 4 ++ .../treehouse/IosTreehouseDispatchers.kt | 57 +++++++++++++------ .../redwood/treehouse/IosTreehousePlatform.kt | 3 + .../treehouse/treehouseAppFactoryIos.kt | 4 +- .../treehouse/IosTreehouseDispatchersTest.kt | 2 +- 18 files changed, 169 insertions(+), 50 deletions(-) create mode 100644 redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/FakeZiplineLoaderDispatcher.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f475a79c1a..b07f65e47d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ Fixed: - Don't crash in `LazyList` when a scroll and content change occur in the same update. - Updating a flex container's margin now works correctly for Yoga-based layouts. +Breaking: +- The `TreehouseApp.Factory.dispatchers` property is removed, and callers should migrate to `TreehouseApp.dispatchers`. With this update each `TreehouseApp` has its own private thread so a shared `dispatchers` property no longer fits our implementation. + Upgraded: - Zipline 1.14.0 diff --git a/redwood-treehouse-host/api/android/redwood-treehouse-host.api b/redwood-treehouse-host/api/android/redwood-treehouse-host.api index fe04ae89cc..2558063ed1 100644 --- a/redwood-treehouse-host/api/android/redwood-treehouse-host.api +++ b/redwood-treehouse-host/api/android/redwood-treehouse-host.api @@ -95,7 +95,6 @@ public abstract class app/cash/redwood/treehouse/TreehouseApp : java/lang/AutoCl public abstract interface class app/cash/redwood/treehouse/TreehouseApp$Factory : java/lang/AutoCloseable { public abstract fun create (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/redwood/treehouse/TreehouseApp$Spec;Lapp/cash/redwood/treehouse/EventListener$Factory;)Lapp/cash/redwood/treehouse/TreehouseApp; public static synthetic fun create$default (Lapp/cash/redwood/treehouse/TreehouseApp$Factory;Lkotlinx/coroutines/CoroutineScope;Lapp/cash/redwood/treehouse/TreehouseApp$Spec;Lapp/cash/redwood/treehouse/EventListener$Factory;ILjava/lang/Object;)Lapp/cash/redwood/treehouse/TreehouseApp; - public abstract fun getDispatchers ()Lapp/cash/redwood/treehouse/TreehouseDispatchers; } public abstract class app/cash/redwood/treehouse/TreehouseApp$Spec { diff --git a/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api b/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api index 3a146167db..4c270b05c3 100644 --- a/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api +++ b/redwood-treehouse-host/api/jvm/redwood-treehouse-host.api @@ -95,7 +95,6 @@ public abstract class app/cash/redwood/treehouse/TreehouseApp : java/lang/AutoCl public abstract interface class app/cash/redwood/treehouse/TreehouseApp$Factory : java/lang/AutoCloseable { public abstract fun create (Lkotlinx/coroutines/CoroutineScope;Lapp/cash/redwood/treehouse/TreehouseApp$Spec;Lapp/cash/redwood/treehouse/EventListener$Factory;)Lapp/cash/redwood/treehouse/TreehouseApp; public static synthetic fun create$default (Lapp/cash/redwood/treehouse/TreehouseApp$Factory;Lkotlinx/coroutines/CoroutineScope;Lapp/cash/redwood/treehouse/TreehouseApp$Spec;Lapp/cash/redwood/treehouse/EventListener$Factory;ILjava/lang/Object;)Lapp/cash/redwood/treehouse/TreehouseApp; - public abstract fun getDispatchers ()Lapp/cash/redwood/treehouse/TreehouseDispatchers; } public abstract class app/cash/redwood/treehouse/TreehouseApp$Spec { diff --git a/redwood-treehouse-host/api/redwood-treehouse-host.klib.api b/redwood-treehouse-host/api/redwood-treehouse-host.klib.api index ac83cd3047..62d2edd4e2 100644 --- a/redwood-treehouse-host/api/redwood-treehouse-host.klib.api +++ b/redwood-treehouse-host/api/redwood-treehouse-host.klib.api @@ -78,9 +78,6 @@ abstract class <#A: app.cash.redwood.treehouse/AppService> app.cash.redwood.tree abstract fun stop() // app.cash.redwood.treehouse/TreehouseApp.stop|stop(){}[0] abstract interface Factory : kotlin/AutoCloseable { // app.cash.redwood.treehouse/TreehouseApp.Factory|null[0] - abstract val dispatchers // app.cash.redwood.treehouse/TreehouseApp.Factory.dispatchers|{}dispatchers[0] - abstract fun (): app.cash.redwood.treehouse/TreehouseDispatchers // app.cash.redwood.treehouse/TreehouseApp.Factory.dispatchers.|(){}[0] - abstract fun <#A2: app.cash.redwood.treehouse/AppService> create(kotlinx.coroutines/CoroutineScope, app.cash.redwood.treehouse/TreehouseApp.Spec<#A2>, app.cash.redwood.treehouse/EventListener.Factory = ...): app.cash.redwood.treehouse/TreehouseApp<#A2> // app.cash.redwood.treehouse/TreehouseApp.Factory.create|create(kotlinx.coroutines.CoroutineScope;app.cash.redwood.treehouse.TreehouseApp.Spec<0:0>;app.cash.redwood.treehouse.EventListener.Factory){0ยง}[0] } diff --git a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/AndroidTreehouseDispatchers.kt b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/AndroidTreehouseDispatchers.kt index 0f3f0e3e66..1ad6c17bd9 100644 --- a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/AndroidTreehouseDispatchers.kt +++ b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/AndroidTreehouseDispatchers.kt @@ -17,20 +17,22 @@ package app.cash.redwood.treehouse import android.os.Looper import java.util.concurrent.Executors +import kotlinx.coroutines.CloseableCoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher /** * Implements [TreehouseDispatchers] suitable for production Android use. This creates a background * thread for all Zipline work. */ -internal class AndroidTreehouseDispatchers : TreehouseDispatchers { +internal class AndroidTreehouseDispatchers(applicationName: String) : TreehouseDispatchers { private var ziplineThread: Thread? = null /** The single thread that runs all JavaScript. We only have one QuickJS instance at a time. */ private val executorService = Executors.newSingleThreadExecutor { runnable -> - Thread(null, runnable, "Treehouse", ZIPLINE_THREAD_STACK_SIZE.toLong()) + Thread(null, runnable, "Treehouse $applicationName", ZIPLINE_THREAD_STACK_SIZE.toLong()) .also { ziplineThread = it } } @@ -49,3 +51,11 @@ internal class AndroidTreehouseDispatchers : TreehouseDispatchers { executorService.shutdown() } } + +@OptIn(ExperimentalCoroutinesApi::class) // CloseableCoroutineDispatcher is experimental. +internal fun ziplineLoaderDispatcher(): CloseableCoroutineDispatcher { + val executorService = Executors.newSingleThreadExecutor { runnable -> + Thread(null, runnable, "ZiplineLoader") + } + return executorService.asCoroutineDispatcher() +} diff --git a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/AndroidTreehousePlatform.kt b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/AndroidTreehousePlatform.kt index c6b608e3ec..1a7afafaf2 100644 --- a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/AndroidTreehousePlatform.kt +++ b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/AndroidTreehousePlatform.kt @@ -45,4 +45,7 @@ internal class AndroidTreehousePlatform( maxSizeInBytes = maxSizeInBytes, loaderEventListener = loaderEventListener, ) + + override fun newDispatchers(applicationName: String): TreehouseDispatchers = + AndroidTreehouseDispatchers(applicationName) } diff --git a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryAndroid.kt b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryAndroid.kt index 46edcb3086..ee33d94a27 100644 --- a/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryAndroid.kt +++ b/redwood-treehouse-host/src/androidMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryAndroid.kt @@ -19,11 +19,13 @@ import android.content.Context import app.cash.zipline.loader.LoaderEventListener import app.cash.zipline.loader.ManifestVerifier import app.cash.zipline.loader.asZiplineHttpClient +import kotlinx.coroutines.ExperimentalCoroutinesApi import okhttp3.OkHttpClient import okio.FileSystem import okio.Path @Suppress("FunctionName") +@OptIn(ExperimentalCoroutinesApi::class) // CloseableCoroutineDispatcher is experimental. public fun TreehouseAppFactory( context: Context, httpClient: OkHttpClient, @@ -37,7 +39,6 @@ public fun TreehouseAppFactory( stateStore: StateStore = MemoryStateStore(), ): TreehouseApp.Factory = RealTreehouseApp.Factory( platform = AndroidTreehousePlatform(context), - dispatchers = AndroidTreehouseDispatchers(), httpClient = httpClient.asZiplineHttpClient(), frameClockFactory = AndroidChoreographerFrameClock.Factory(), manifestVerifier = manifestVerifier, @@ -45,6 +46,7 @@ public fun TreehouseAppFactory( embeddedDir = embeddedDir, cacheName = cacheName, cacheMaxSizeInBytes = cacheMaxSizeInBytes, + ziplineLoaderDispatcher = ziplineLoaderDispatcher(), loaderEventListener = loaderEventListener, concurrentDownloads = concurrentDownloads, stateStore = stateStore, diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/FakeZiplineLoaderDispatcher.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/FakeZiplineLoaderDispatcher.kt new file mode 100644 index 0000000000..7bf1384498 --- /dev/null +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/FakeZiplineLoaderDispatcher.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.treehouse + +import java.util.concurrent.Executor +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CloseableCoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.test.TestScope + +@OptIn(ExperimentalCoroutinesApi::class) // CloseableCoroutineDispatcher is experimental. +internal class FakeZiplineLoaderDispatcher( + testScope: TestScope, +) : CloseableCoroutineDispatcher() { + private val delegate = testScope.dispatcher() + + var closed = false + private set + + override fun dispatch(context: CoroutineContext, block: Runnable) { + delegate.dispatch(context, block) + } + + override val executor: Executor + get() = error("unexpected call") + + override fun close() { + closed = true + } +} diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt index 1ef10347f1..3d349b69e5 100644 --- a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt @@ -189,20 +189,21 @@ class LeaksTest { } @Test - fun dispatchersNotClosedByApp() = runTest { - val dispatchers = FakeDispatchers(this) - val app = TreehouseTester(this, dispatchers).loadApp() + fun treehouseDispatchersClosedByApp() = runTest { + val treehouseTester = TreehouseTester(this) + val app = treehouseTester.loadApp() + assertThat(treehouseTester.openTreehouseDispatchersCount).isEqualTo(1) app.close() - assertThat(dispatchers.isClosed).isFalse() + assertThat(treehouseTester.openTreehouseDispatchersCount).isEqualTo(0) + assertThat(treehouseTester.ziplineLoaderDispatcher.closed).isFalse() } @Test - fun dispatchersNotLeakedByAppFactory() = runTest { - val dispatchers = FakeDispatchers(this) - val factory = TreehouseTester(this, dispatchers).treehouseAppFactory - assertThat(dispatchers.isClosed).isFalse() - factory.close() - assertThat(dispatchers.isClosed).isTrue() + fun ziplineLoaderDispatcherClosedByAppFactory() = runTest { + val treehouseTester = TreehouseTester(this) + assertThat(treehouseTester.ziplineLoaderDispatcher.closed).isFalse() + treehouseTester.treehouseAppFactory.close() + assertThat(treehouseTester.ziplineLoaderDispatcher.closed).isTrue() } /** diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt index 354d53fba6..d896948250 100644 --- a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt @@ -22,6 +22,7 @@ import app.cash.zipline.loader.ZiplineHttpClient import com.example.redwood.testapp.treehouse.HostApi import com.example.redwood.testapp.treehouse.TestAppPresenter import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -40,7 +41,6 @@ import okio.Path.Companion.toPath */ internal class TreehouseTester( private val testScope: TestScope, - dispatchers: FakeDispatchers = FakeDispatchers(testScope), ) { val eventLog = EventLog() @@ -52,6 +52,8 @@ internal class TreehouseTester( private val kotlinZiplineDir = "../test-app/presenter-treehouse/build/zipline/Development".toPath() + private val returnedTreehouseDispatchers = mutableListOf() + private val httpClient = object : ZiplineHttpClient() { override suspend fun download( url: String, @@ -72,6 +74,13 @@ internal class TreehouseTester( maxSizeInBytes: Long, loaderEventListener: LoaderEventListener, ) = error("unexpected call") + + override fun newDispatchers( + applicationName: String, + ): FakeDispatchers { + return FakeDispatchers(testScope) + .also { returnedTreehouseDispatchers += it } + } } private var appLifecycleAwaitingAFrame = MutableStateFlow(null) @@ -86,9 +95,11 @@ internal class TreehouseTester( override fun create(scope: CoroutineScope, dispatchers: TreehouseDispatchers) = frameClock } + val ziplineLoaderDispatcher = FakeZiplineLoaderDispatcher(testScope) + + @OptIn(ExperimentalCoroutinesApi::class) // CloseableCoroutineDispatcher is experimental. val treehouseAppFactory = RealTreehouseApp.Factory( platform = platform, - dispatchers = dispatchers, httpClient = httpClient, frameClockFactory = frameClockFactory, manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, @@ -96,11 +107,15 @@ internal class TreehouseTester( embeddedDir = null, cacheName = "cache", cacheMaxSizeInBytes = 0L, + ziplineLoaderDispatcher = ziplineLoaderDispatcher, concurrentDownloads = 1, loaderEventListener = LoaderEventListener.None, stateStore = MemoryStateStore(), ) + val openTreehouseDispatchersCount: Int + get() = returnedTreehouseDispatchers.count { !it.isClosed } + private val appSpec = object : TreehouseApp.Spec() { override val name: String get() = "test_app" diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt index f29d1bd050..b21096231c 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/RealTreehouseApp.kt @@ -22,7 +22,9 @@ import app.cash.zipline.loader.LoaderEventListener import app.cash.zipline.loader.ManifestVerifier import app.cash.zipline.loader.ZiplineHttpClient import app.cash.zipline.loader.ZiplineLoader +import kotlinx.coroutines.CloseableCoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.mapNotNull @@ -33,6 +35,7 @@ internal class RealTreehouseApp private constructor( private val factory: Factory, private val appScope: CoroutineScope, override val spec: Spec, + override val dispatchers: TreehouseDispatchers, eventListenerFactory: EventListener.Factory, ) : TreehouseApp() { /** This property is confined to [TreehouseDispatchers.ui]. */ @@ -41,8 +44,6 @@ internal class RealTreehouseApp private constructor( /** Non-null until this app is closed. This property is confined to [TreehouseDispatchers.ui]. */ private var eventListenerFactory: EventListener.Factory? = eventListenerFactory - override val dispatchers = factory.dispatchers - private val codeHost = object : CodeHost( dispatchers = dispatchers, appScope = appScope, @@ -122,6 +123,8 @@ internal class RealTreehouseApp private constructor( if (!spec.loadCodeFromNetworkOnly) { loader = loader.withCache( cache = factory.cache.value, + // TODO(jwilson): use this once we update Zipline. + // cacheDispatcher = factory.ziplineLoaderDispatcher, ) if (factory.embeddedFileSystem != null && factory.embeddedDir != null) { @@ -165,11 +168,17 @@ internal class RealTreehouseApp private constructor( closed = true eventListenerFactory = null stop() + dispatchers.close() } + /** + * @param ziplineLoaderDispatcher a dispatcher backed by a single thread, that's owned by this + * factory. If it's a [CloseableCoroutineDispatcher], it will be closed when this factory is + * closed. + */ + @OptIn(ExperimentalCoroutinesApi::class) // CloseableCoroutineDispatcher is experimental. class Factory internal constructor( private val platform: TreehousePlatform, - override val dispatchers: TreehouseDispatchers, internal val httpClient: ZiplineHttpClient, internal val frameClockFactory: FrameClock.Factory, internal val manifestVerifier: ManifestVerifier, @@ -177,6 +186,7 @@ internal class RealTreehouseApp private constructor( internal val embeddedDir: Path?, private val cacheName: String, private val cacheMaxSizeInBytes: Long, + internal val ziplineLoaderDispatcher: CloseableCoroutineDispatcher, private val loaderEventListener: LoaderEventListener, internal val concurrentDownloads: Int, internal val stateStore: StateStore, @@ -194,14 +204,20 @@ internal class RealTreehouseApp private constructor( appScope: CoroutineScope, spec: Spec, eventListenerFactory: EventListener.Factory, - ): TreehouseApp = RealTreehouseApp(this, appScope, spec, eventListenerFactory) + ): TreehouseApp = RealTreehouseApp( + factory = this, + appScope = appScope, + spec = spec, + dispatchers = platform.newDispatchers(spec.name), + eventListenerFactory = eventListenerFactory, + ) override fun close() { if (cache.isInitialized()) { cache.value.close() } - dispatchers.close() + ziplineLoaderDispatcher.close() } } } 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 f9f76000e6..93e56d0492 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 @@ -104,8 +104,6 @@ public abstract class TreehouseApp : AutoCloseable { */ @ObjCName("TreehouseAppFactory", exact = true) public interface Factory : AutoCloseable { - public val dispatchers: TreehouseDispatchers - public fun create( appScope: CoroutineScope, spec: Spec, 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 6b90452150..e3874e59ba 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 @@ -25,6 +25,9 @@ import kotlinx.coroutines.CoroutineDispatcher * * [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. + * + * Each [TreehouseApp] gets its own instance of this class and has its own private Zipline thread. + * All apps share the same UI thread. */ @ObjCName("TreehouseDispatchers", exact = true) public interface TreehouseDispatchers : AutoCloseable { @@ -48,10 +51,9 @@ public interface TreehouseDispatchers : AutoCloseable { /** * Release the threads owned by this instance. On most platforms this will not release the UI - * thread, as it is not owned by this instance. + * thread as it is not owned by this instance. * - * Most applications should not to call this; instead they should allow these dispatchers to - * run until the process exits. This may be useful in tests. + * This is called by [TreehouseApp.close]. */ public override fun close() } diff --git a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehousePlatform.kt b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehousePlatform.kt index 01773255fc..a87c7731b7 100644 --- a/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehousePlatform.kt +++ b/redwood-treehouse-host/src/commonMain/kotlin/app/cash/redwood/treehouse/TreehousePlatform.kt @@ -24,4 +24,8 @@ internal interface TreehousePlatform { maxSizeInBytes: Long, loaderEventListener: LoaderEventListener, ): ZiplineCache + + fun newDispatchers( + applicationName: String, + ): TreehouseDispatchers } diff --git a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/IosTreehouseDispatchers.kt b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/IosTreehouseDispatchers.kt index 6d74e9d425..709c48c456 100644 --- a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/IosTreehouseDispatchers.kt +++ b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/IosTreehouseDispatchers.kt @@ -26,15 +26,43 @@ import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.runBlocking import platform.Foundation.NSThread -internal class IosTreehouseDispatchers : - CloseableCoroutineDispatcher(), - TreehouseDispatchers { +internal class IosTreehouseDispatchers(applicationName: String) : TreehouseDispatchers { + /** + * On Apple platforms we need to explicitly set the stack size for background threads; otherwise + * we get the default of 512 KiB which isn't sufficient for our QuickJS programs. + */ + private val ziplineSingleThreadDispatcher = SingleThreadDispatcher( + name = "Treehouse $applicationName", + stackSize = ZIPLINE_THREAD_STACK_SIZE, + ) + + internal val ziplineThread: NSThread + get() = ziplineSingleThreadDispatcher.thread override val ui: CoroutineDispatcher get() = Dispatchers.Main + override val zipline: CoroutineDispatcher get() = ziplineSingleThreadDispatcher + + override fun checkUi() { + check(NSThread.isMainThread) + } + + override fun checkZipline() { + check(NSThread.currentThread == ziplineThread) + } + + override fun close() { + ziplineSingleThreadDispatcher.close() + } +} + +internal class SingleThreadDispatcher( + name: String, + stackSize: Int? = null, +) : CloseableCoroutineDispatcher() { private val channel = Channel(capacity = Channel.UNLIMITED) - internal val ziplineThread = NSThread { + internal val thread = NSThread { runBlocking { while (true) { try { @@ -47,25 +75,15 @@ internal class IosTreehouseDispatchers : } } }.apply { - name = "Treehouse" + this.name = name - // On Apple platforms we need to explicitly set the stack size for background threads; otherwise - // we get the default of 512 KiB which isn't sufficient for our QuickJS programs. - stackSize = ZIPLINE_THREAD_STACK_SIZE.convert() + if (stackSize != null) { + this.stackSize = stackSize.convert() + } start() } - override val zipline: CoroutineDispatcher get() = this - - override fun checkUi() { - check(NSThread.isMainThread) - } - - override fun checkZipline() { - check(NSThread.currentThread == ziplineThread) - } - override fun dispatch(context: CoroutineContext, block: Runnable) { channel.trySend(block) } @@ -74,3 +92,6 @@ internal class IosTreehouseDispatchers : channel.close() } } + +internal fun ziplineLoaderDispatcher(): CloseableCoroutineDispatcher = + SingleThreadDispatcher("ZiplineLoader") diff --git a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/IosTreehousePlatform.kt b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/IosTreehousePlatform.kt index 223a688254..d031ba2c66 100644 --- a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/IosTreehousePlatform.kt +++ b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/IosTreehousePlatform.kt @@ -32,4 +32,7 @@ internal class IosTreehousePlatform : TreehousePlatform { maxSizeInBytes = maxSizeInBytes, loaderEventListener = loaderEventListener, ) + + override fun newDispatchers(applicationName: String): TreehouseDispatchers = + IosTreehouseDispatchers(applicationName) } diff --git a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryIos.kt b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryIos.kt index 0039ea2c4c..26004cc3b7 100644 --- a/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryIos.kt +++ b/redwood-treehouse-host/src/iosMain/kotlin/app/cash/redwood/treehouse/treehouseAppFactoryIos.kt @@ -18,10 +18,12 @@ package app.cash.redwood.treehouse import app.cash.zipline.loader.LoaderEventListener import app.cash.zipline.loader.ManifestVerifier import app.cash.zipline.loader.ZiplineHttpClient +import kotlinx.coroutines.ExperimentalCoroutinesApi import okio.FileSystem import okio.Path @Suppress("FunctionName") +@OptIn(ExperimentalCoroutinesApi::class) // CloseableCoroutineDispatcher is experimental. public fun TreehouseAppFactory( httpClient: ZiplineHttpClient, manifestVerifier: ManifestVerifier, @@ -34,7 +36,6 @@ public fun TreehouseAppFactory( stateStore: StateStore = MemoryStateStore(), ): TreehouseApp.Factory = RealTreehouseApp.Factory( platform = IosTreehousePlatform(), - dispatchers = IosTreehouseDispatchers(), httpClient = httpClient, frameClockFactory = IosDisplayLinkClock, manifestVerifier = manifestVerifier, @@ -42,6 +43,7 @@ public fun TreehouseAppFactory( embeddedDir = embeddedDir, cacheName = cacheName, cacheMaxSizeInBytes = cacheMaxSizeInBytes, + ziplineLoaderDispatcher = ziplineLoaderDispatcher(), loaderEventListener = loaderEventListener, concurrentDownloads = concurrentDownloads, stateStore = stateStore, diff --git a/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/IosTreehouseDispatchersTest.kt b/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/IosTreehouseDispatchersTest.kt index 10b7477982..04beae1aaa 100644 --- a/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/IosTreehouseDispatchersTest.kt +++ b/redwood-treehouse-host/src/iosTest/kotlin/app/cash/redwood/treehouse/IosTreehouseDispatchersTest.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout class IosTreehouseDispatchersTest : AbstractTreehouseDispatchersTest() { - private val iosTreehouseDispatchers = IosTreehouseDispatchers() + private val iosTreehouseDispatchers = IosTreehouseDispatchers("appName") override val treehouseDispatchers: TreehouseDispatchers get() = iosTreehouseDispatchers /** We haven't set done the work to dispatch to the UI thread on iOS tests. */