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. */