Skip to content

Don't call Dispatchers.Main getter during UnconfinedTestDispatcher construction #4297

@azabost

Description

@azabost

I'm opening this issue as a result of my short conversation with @dkhalanskyjb on Kotlin Slack: https://kotlinlang.slack.com/archives/C1CFAFJSK/p1734007891082299

What do we have now?

In certain situations, creating an instance of UnconfinedTestDispatcher by calling UnconfinedTestDispatcher() just before setting it as the main dispatcher via Dispatchers.setMain, e.g. in a JUnit rule, may fail.

(The same applies to StandardTestDispatcher)

The execution path of creating the dispatcher was like this:

  1. UnconfinedTestDispatcher
    scheduler ?: TestMainDispatcher.currentTestScheduler ?: TestCoroutineScheduler(), name)
  2. TestMainDispatcher.currentTestScheduler
  3. TestMainDispatcher.currentTestDispatcher
    get() = (Dispatchers.Main as? TestMainDispatcher)?.delegate?.value as? TestDispatcher
  4. Dispatchers.Main (getter invoked before Dispatchers.setMain!)
    public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
  5. MainDispatcherLoader.loadMainDispatcher
  6. MainDispatchersKt.tryCreateDispatcher
  7. kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher
  8. Looper.getMainLooper() ⚡ throws
Stacktrace
kotlinx.coroutines.CoroutinesInternalError: Fatal exception in coroutines machinery for DispatchedContinuation[Dispatchers.IO, Continuation at kotlinx.coroutines.flow.internal.ChannelFlow$collectToFun$1.invokeSuspend(ChannelFlow.kt:56)@6c0c966f]. Please read KDoc to 'handleFatalException' method and report this incident to maintainers
	at app//kotlinx.coroutines.DispatchedTask.handleFatalException$kotlinx_coroutines_core(DispatchedTask.kt:142)
	at app//kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:113)
	at app//kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:111)
	at app//kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:99)
	at app//kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584)
	at app//kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:811)
	at app//kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:715)
	at app//kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:702)
Caused by: java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
	at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:111)
	at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(MainDispatchers.kt:92)
	at kotlinx.coroutines.test.internal.TestMainDispatcher.isDispatchNeeded(TestMainDispatcher.kt:27)
	at kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:156)
	at kotlinx.coroutines.CancellableContinuationImpl.dispatchResume(CancellableContinuationImpl.kt:466)
	at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl(CancellableContinuationImpl.kt:500)
	at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$default(CancellableContinuationImpl.kt:489)
	at kotlinx.coroutines.CancellableContinuationImpl.resumeWith(CancellableContinuationImpl.kt:364)
	at kotlinx.coroutines.channels.BufferedChannel$BufferedChannelIterator.tryResumeHasNextOnClosedChannel(BufferedChannel.kt:1737)
	at kotlinx.coroutines.channels.BufferedChannel.resumeWaiterOnClosedChannel(BufferedChannel.kt:2204)
	at kotlinx.coroutines.channels.BufferedChannel.resumeReceiverOnClosedChannel(BufferedChannel.kt:2191)
	at kotlinx.coroutines.channels.BufferedChannel.cancelSuspendedReceiveRequests(BufferedChannel.kt:2184)
	at kotlinx.coroutines.channels.BufferedChannel.completeClose(BufferedChannel.kt:1961)
	at kotlinx.coroutines.channels.BufferedChannel.isClosed(BufferedChannel.kt:2240)
	at kotlinx.coroutines.channels.BufferedChannel.isClosedForSend0(BufferedChannel.kt:2215)
	at kotlinx.coroutines.channels.BufferedChannel.isClosedForSend(BufferedChannel.kt:2212)
	at kotlinx.coroutines.channels.BufferedChannel.completeCloseOrCancel(BufferedChannel.kt:1933)
	at kotlinx.coroutines.channels.BufferedChannel.closeOrCancelImpl(BufferedChannel.kt:1826)
	at kotlinx.coroutines.channels.BufferedChannel.close(BufferedChannel.kt:1785)
	at kotlinx.coroutines.channels.SendChannel$DefaultImpls.close$default(Channel.kt:95)
	at kotlinx.coroutines.channels.ProducerCoroutine.onCompleted(Produce.kt:140)
	at kotlinx.coroutines.channels.ProducerCoroutine.onCompleted(Produce.kt:133)
	at kotlinx.coroutines.AbstractCoroutine.onCompletionInternal(AbstractCoroutine.kt:90)
	at kotlinx.coroutines.JobSupport.tryFinalizeSimpleState(JobSupport.kt:293)
	at kotlinx.coroutines.JobSupport.tryMakeCompleting(JobSupport.kt:867)
	at kotlinx.coroutines.JobSupport.makeCompletingOnce$kotlinx_coroutines_core(JobSupport.kt:839)
	at kotlinx.coroutines.AbstractCoroutine.resumeWith(AbstractCoroutine.kt:97)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:46)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
	... 6 more
Caused by: java.lang.IllegalStateException: The main looper is not available
	at kotlinx.coroutines.android.AndroidDispatcherFactory.createDispatcher(HandlerDispatcher.kt:51)
	at kotlinx.coroutines.internal.MainDispatchersKt.tryCreateDispatcher(MainDispatchers.kt:53)
	at kotlinx.coroutines.test.internal.TestMainDispatcherFactory.createDispatcher(TestMainDispatcherJvm.kt:11)
	at kotlinx.coroutines.internal.MainDispatchersKt.tryCreateDispatcher(MainDispatchers.kt:53)
	at kotlinx.coroutines.internal.MainDispatcherLoader.loadMainDispatcher(MainDispatchers.kt:34)
	at kotlinx.coroutines.internal.MainDispatcherLoader.<clinit>(MainDispatchers.kt:18)
	at kotlinx.coroutines.Dispatchers.getMain(Dispatchers.kt:20)
	at kotlinx.coroutines.test.internal.TestMainDispatcher$Companion.getCurrentTestDispatcher$kotlinx_coroutines_test(TestMainDispatcher.kt:47)
	at kotlinx.coroutines.test.internal.TestMainDispatcher$Companion.getCurrentTestScheduler$kotlinx_coroutines_test(TestMainDispatcher.kt:50)
	at kotlinx.coroutines.test.TestCoroutineDispatchersKt.UnconfinedTestDispatcher(TestCoroutineDispatchers.kt:83)
	at kotlinx.coroutines.test.TestCoroutineDispatchersKt.UnconfinedTestDispatcher$default(TestCoroutineDispatchers.kt:79)
	at com.example.MainDispatcherRule.<init>(MainDispatcherRule.kt:14)
	at com.example.Test.<init>(Test.kt:51)
	(...)

It prevented me from setting the UnconfinedTestDispatcher as Main because it wasn't possible to construct it in the first place. The error also wasn't helpful because it told me to call setMain which happened to be the exact thing I was trying to accomplish.

To be perfectly honest, I'm not sure how exactly to reproduce these circumstances. The project where I stumbled upon this problem was large and had a complicated setup. I wouldn't rule out the possibility of a user error or a misconfiguration of some sort, such as a version conflict in dependencies or a test classpath pollution (e.g. having ...-android in the test classpath). Nevertheless, it would be great if this problem could be avoided somehow.

What should be instead?

If possible, the code necessary for substituting the main dispatcher (such as the UnconfinedTestDispatcher() or StandardTestDispatcher() function) should not call Dispatchers.Main (getter) because it leads to strange situations like the one I described where the user is told to call setMain even though that's exactly what the user is trying to accomplish.

Why?

The upsides of your proposal.

  • Who would benefit from this and how? - People who accidentally ended up with coroutines-android in unit tests, I suppose
    • They wouldn't be told to do exactly the thing they are trying to do when they get the exception

Why not?

The downsides of your proposal that you already see.

  • Is this a breaking change? - Probably not
  • Are there use cases that are better solved by what we have now? - I don't know
  • Does some code become less clear after this change? - Good question
  • It was the only project where I found this problem and it makes me believe it's a user error after all.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions