Headless concurrent Compose testing and `Snapshot`...
# compose
o
Headless concurrent Compose testing and
Snapshot
multi-threading: "_Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied_". Details in 🧵.
I'm trying to do a headless test of many compose frontends (thousands) communicating concurrently with a single backend, all sharing a single JVM. Each frontend runs a special Compose setup (GlobalSnapshotManager, 60 fps frame clock, Applier, Recomposer) in its own (frontend-specific "main") thread. The
GlobalSnapshotManager
has its own thread. Also, each frontend continuously receives asynchronous updates to
MutableState
from the backend.
Copy code
class TestFrontend {
    // ...

    fun launchIn(testScope: CoroutineScope) {
        GlobalSnapshotManager.ensureStarted()

        job = (testScope + Executors.newSingleThreadExecutor().asCoroutineDispatcher()).launch {
            withContext(SixtyFpsMonotonicFrameClock) {
                val viewTree = ViewNode()
                val recomposer = Recomposer(coroutineContext)
                val composition = Composition(ViewNode.Applier(viewTree), recomposer)

                try {
                    composition.setContent {
                        FrontendView(/* ... */) // uses LaunchedEffect to run backend communications
                    }

                    recomposer.runRecomposeAndApplyChanges()
                } finally {
                    composition.dispose()
                }
            }
        }
    }

    // ...
}
So far, the setup works on 4 CPU cores with 20 frontends, sometimes with 200 (depending on JVM warm-up), but fails with 2000: "Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied" I have found this explanation on thread-local snapshots: https://kotlinlang.slack.com/archives/CJLTWPH7S/p1613583271168600?thread_ts=1613581738.163700&cid=CJLTWPH7S My questions are: 1. Is the Snapshot system capable of supporting multiple `Recomposer`s as long as each of them runs on its own "main" thread? 2. Do asynchronous updates to
MutableState
have to run on the corresponding "main" thread? 3. What else should I look for when structuring coroutines and thread confinement?
Investigating a bit further, I can probably answer question 1: Yes, it is possible. After moving all initializations of
MutableState
onto the respective frontend's "main" thread, my test ran with 2000 concurrent frontends without crashing.
A problem related to question 2: I could not make
MutableState
updates work from threads other than the respective "main". Running a top-level Composable like this works with 20 concurrent frontends, but produces the read error above with 2000 frontends:
Copy code
@Composable
fun FrontendView(id: Int, testApplication: TestApplication) {
    val service = remember { TestFrontendService(id, testApplication) } // initializes MutableState

    LaunchedEffect(Unit) {
        withContext(<http://Dispatchers.IO|Dispatchers.IO>) {
            service.launchIn(this) // changes MutableState
        }
    }

    // Composables accessing MutableState from service
}
Removing
withContext(<http://Dispatchers.IO|Dispatchers.IO>)
makes it work.
And finally, I wonder why the snapshot system uses (its own version of) plain `ThreadLocal`s for isolation. With everything running on coroutines, I'd expect it to use the asContextElement extension function on
ThreadLocal
which would allow coroutine-style concurrency, eliminating the need to create thousands of threads. Any ideas?
z
How are you accessing GlobalSnapshotManager? Isn’t it internal?
To your ThreadLocal question, I think it’s because a mutable snapshot is not meant to be accessed from multiple threads in most cases, and even then only very carefully with external synchronization. Generally making suspending calls inside a nested snapshot is officially unsupported I think, we don’t have any use cases, and so the effort that would be required to support what your asking and ensure all the edge cases is probably not justifiable. That’s partially speculation though, and there might be other reasons. @Chuck Jazdzewski [G] would know for sure.
o
I've created a custom version of
GlobalSnapshotManager
like so:
Copy code
object GlobalSnapshotManager {
    private val started = AtomicBoolean(false)

    fun ensureStarted() {
        if (started.compareAndSet(false, true)) {
            val snapshotWriteObservation = Channel<Unit>(Channel.CONFLATED)
            CoroutineScope(singleThreadDispatcher()).launch {
                snapshotWriteObservation.consumeEach {
                    Snapshot.sendApplyNotifications()
                }
            }
            Snapshot.registerGlobalWriteObserver {
                logger.debug("write observed: $it")
                snapshotWriteObservation.trySend(Unit)
            }
        }
    }
}
And the idea of updating state in another thread came from this excellent piece of yours, specifically the section "Multithreading and the global snapshot", stating:
Consider a network call made from a
LaunchedEffect
on the
IO
dispatcher that updates a
MutableState
with the response. This code mutates a snapshot state value, without a snapshot, in a background thread (remember: Compose only wraps compositions in snapshots, and effects are executed outside of compositions).
z
Updating state in another thread is fine, as far as snapshots themselves are concerned. Whether it’s guaranteed to work with multiple Recomposers running on different threads… idk
o
OK, so seems like there's life on the edge (again). 🙃 This is the only way I could envision for massive end-to-(almost-)end concurrency testing. Launching thousands of emulators, desktop apps or browser engines would probably take soooo much longer...