JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
15.3k stars 1.11k forks source link

IllegalStateException when using Dispatchers.IO inside Composition.setContent #4562

Open mgroth0 opened 3 months ago

mgroth0 commented 3 months ago

Describe the bug

java.lang.IllegalStateException: Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
    at androidx.compose.runtime.snapshots.SnapshotKt.readError(Snapshot.kt:1930)
    at androidx.compose.runtime.snapshots.SnapshotKt.current(Snapshot.kt:2275)
    at androidx.compose.runtime.SnapshotMutableStateImpl.setValue(SnapshotState.kt:308)
    at matt.compose.components.filetree.fttests.bug.ComposableSingletons$BugKt$lambda-1$1$1$1$1.invokeSuspend(bug.kt:28)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
    at androidx.compose.ui.platform.FlushCoroutineDispatcher$dispatch$2$1.invoke(FlushCoroutineDispatcher.skiko.kt:62)
    at androidx.compose.ui.platform.FlushCoroutineDispatcher$dispatch$2$1.invoke(FlushCoroutineDispatcher.skiko.kt:57)
    at androidx.compose.ui.platform.FlushCoroutineDispatcher.performRun(FlushCoroutineDispatcher.skiko.kt:99)
    at androidx.compose.ui.platform.FlushCoroutineDispatcher.access$performRun(FlushCoroutineDispatcher.skiko.kt:37)
    at androidx.compose.ui.platform.FlushCoroutineDispatcher$dispatch$2.invokeSuspend(FlushCoroutineDispatcher.skiko.kt:57)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:363)
    at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26)
    at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable$default(Cancellable.kt:21)
    at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:88)
    at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:123)
    at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:52)
    at kotlinx.coroutines.BuildersKt.launch(Unknown Source)
    at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:43)
    at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source)
    at androidx.compose.ui.platform.FlushCoroutineDispatcher.dispatch(FlushCoroutineDispatcher.skiko.kt:56)
    at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:318)
    at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith$default(DispatchedContinuation.kt:274)
    at kotlinx.coroutines.DispatchedCoroutine.afterResume(Builders.common.kt:257)
    at kotlinx.coroutines.AbstractCoroutine.resumeWith(AbstractCoroutine.kt:99)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:46)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
    at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:111)
    at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:99)
    at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:811)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:715)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:702)

Affected platforms

Versions

To Reproduce

Note that this bug is not 100% deterministic. Sometimes this has to be run 5 or so times before an exception is thrown

import androidx.compose.runtime.AbstractApplier
import androidx.compose.runtime.Composition
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.test.Test
import kotlin.test.assertFails

@OptIn(ExperimentalTestApi::class)
class SnapshotStateBug {
    @Test
    fun baseFailureCondition() {
        val exception = assertFails { runTest { withContext(Dispatchers.IO) { false } } }
        exception.printStackTrace()
    }
    @Test
    fun passesWithNoDispatch() {
        runTest { false }
    }

    @Test
    fun passesWithSleep() {
        runTest {
            withContext(Dispatchers.IO) {
                Thread.sleep(100)
                false
            }
        }
    }

    private fun runTest(
        valueProvider: suspend () -> Boolean
    ) {
        runComposeUiTest {
            setContent {
                val applier = remember { TestApplier() }
                val compositionContext = rememberCompositionContext()
                val composition = remember(applier, compositionContext) { Composition(applier, compositionContext) }
                val result = remember { mutableStateOf(true) }
                composition.setContent {
                    LaunchedEffect(Unit) {
                        result.value = valueProvider()
                    }
                }
            }
        }
    }
}

class TestApplier: AbstractApplier<Any?>(null) {
    override fun insertBottomUp(
        index: Int,
        instance: Any?
    ) = TODO("Not yet implemented")

    override fun insertTopDown(
        index: Int,
        instance: Any?
    ) = TODO("Not yet implemented")

    override fun move(
        from: Int,
        to: Int,
        count: Int
    ) = TODO("Not yet implemented")

    override fun onClear() = TODO("Not yet implemented")

    override fun remove(
        index: Int,
        count: Int
    ) = TODO("Not yet implemented")
}

Expected behavior

Additional context

The reason I found this bug is because I use the library Bonsai. In their file tree component, I am getting this error.

While creating a reproducer, I tried to simplify the reproducer as much as possible. Eventually, I broke down the function cafe.adriel.bonsai.core.tree.Tree and implemented it myself in the test you see above. This is when I disocvered that it was possible to reproduce this bug purely from Compose imports. Since I didn't have to import any Bonsai members, I thought this bug report belonged here and not in the Bonsai repository.

I barely have any understanding about how Applier or Composition work. I never use them myself. The only reason I used them here is because Bonsai uses them, and I copied the way that library used them to reproduce this bug.

I included 1 failure condition and 2 passing conditions in the reproducer to highlight how strange this behavior is. The way I would describe it is that the error only occurs if you use an IO dispatch that is super fast. If you have no IO dispatch at all, or an IO dispatch that takes some time, no error. Pretty stange, right?

mgroth0 commented 3 months ago

For my actual application, my workaround (which is not very elegant) is to add Thread.sleep(100) before certain IO operations. This prevents the error from being thrown.