Engineering

Dispatchers.Main throws on a target you forgot to configure

Main is supplied by a platform integration, so it exists on Android and iOS and fails at runtime on desktop tests — a crash whose message names a module, not a mistake.

MobileByteSensei 10 min read
kmpcoroutinesarchitecture

TL;DR

  • Dispatchers.Main worked on Android, worked on iOS, and threw IllegalStateException: Module with the Main dispatcher is missing on the desktop target — which is where we ran 71% of our tests, so 340 of them went red at once.
  • Dispatchers.Main is not part of kotlinx-coroutines-core. It is a service-loaded integration, supplied by kotlinx-coroutines-android, the Darwin runtime, or kotlinx-coroutines-swing / -javafx — and by nothing at all on a bare JVM.
  • The fix is not to add a Swing dependency to a headless test target. It is to stop a shared ViewModel from naming a dispatcher at all.
  • What reading on buys: the four supply mechanisms measured per target, why Dispatchers.Main in shared code is a design error rather than a configuration gap, and the one-line test rule that catches the next one.

The case this post is about

A shared ViewModel, written the way an Android one is written:

// core/common/src/commonMain/kotlin/BaseViewModel.kt
abstract class BaseViewModel {
    // One scope per ViewModel, on Main, because the state it mutates is read by the UI.
    protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    fun clear() = scope.cancel()
}
// feature/profile/src/commonMain/kotlin/ProfileViewModel.kt
class ProfileViewModel(private val repo: ProfileRepo) : BaseViewModel() {
    private val _state = MutableStateFlow(ProfileState.Loading)
    val state = _state.asStateFlow()

    init {
        scope.launch { _state.value = ProfileState.Ready(repo.load()) }
    }
}

And the desktop target, which exists as the fast oracle for tests rather than as a shipped app:

// build.gradle.kts
jvm("desktop")      // no Compose Desktop, no Swing — a headless JVM for tests

The symptom

Every test that constructed a ViewModel:

java.lang.IllegalStateException: Module with the Main dispatcher is missing.
  Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android'
  For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
    at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:116)
    at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(…)
    at kotlinx.coroutines.CoroutineDispatcher.dispatch(…)

340 tests, all at once, after a dependency bump that had nothing to do with coroutines:

# test runs by target, before the break
  target                tests   where we actually run them
  desktop                340    71%  ← the fast oracle
  android unit            88    18%
  iosSimulatorArm64       52    11%  (slow; CI only)

# after
  desktop              0 pass / 340 fail

And note what the message suggests: Dispatchers.setMain. Which is correct advice for a test and reads as "your test setup is incomplete" — so the first response was to add a rule to 340 tests.

Three hundred and forty desktop tests failing at once after a dependency bump, representing seventy-one percent of where tests are actually run

Why the obvious fix failed

The first fix was the one the exception recommends:

// a base test class, applied everywhere
@BeforeTest fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
@AfterTest  fun tearDown() = Dispatchers.resetMain()

This works. It also requires every test that transitively constructs a ViewModel to remember it, which is not a property anything enforces — and the failure for forgetting is the same opaque exception, now in a test that looks unrelated. We applied it to 340 tests and within a week had three new tests failing the same way.

The second fix was to give the desktop target a Main dispatcher:

desktopMain.dependencies { implementation(libs.kotlinx.coroutines.swing) }

Which makes the exception go away and quietly starts an AWT event thread in a headless test process. Measured: 1.8 seconds added to the test task's startup and a JVM that no longer exits cleanly on CI, because the EDT is a non-daemon thread.

  configuration                      :core:common:desktopTest   JVM exits cleanly?
  no Main dispatcher                 fails                      —
  + coroutines-swing                 passes, +1.8s startup       no (EDT lingers)
  + coroutines-swing, headless=true  passes, +1.8s startup       yes (with -Djava.awt.headless)

Shipping a UI toolkit into a headless target to satisfy a dispatcher that nothing dispatches to is the part that should have stopped us, and did not for a day.

The third attempt was Dispatchers.Main behind an expect/actual so desktop could supply Unconfined. Which is a correct mechanism applied to the wrong question — it makes the shared code work on four targets while still asserting that shared code gets to decide the thread.

The actual mechanism

Dispatchers.Main is declared in kotlinx-coroutines-core and implemented nowhere in it. It is resolved at first access through a platform-specific supply mechanism:

  target                  supplied by                              mechanism
  Android                 kotlinx-coroutines-android               ServiceLoader (MainDispatcherFactory)
  iOS / Darwin            coroutines-core's Darwin source set      compiled in; dispatch_get_main_queue
  JS / Wasm               coroutines-core's JS source set          compiled in; the single event loop
  JVM (desktop)           -swing or -javafx, if you add one        ServiceLoader
  JVM (bare)              NOTHING                                  → MissingMainCoroutineDispatcher

Three things follow, and they are the whole post:

It is a link-time-absent, runtime-present dependency. Nothing fails to compile. The symbol Dispatchers.Main exists in core on every target; what varies is whether a factory is found at first dispatch. So the failure cannot arrive before the code runs, and it arrives as a MissingMainCoroutineDispatcher whose isDispatchNeeded throws — which is why the stack trace shows coroutines internals rather than your call site.

On Android the factory is found via ServiceLoader, so it depends on the artifact being on the runtime classpath, not on an import. A shrinker that strips META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory produces exactly this exception in a release build and not in debug — a separate incident, same mechanism.

And a bare JVM has no main thread in the relevant sense. There is a thread named main, and it is not an event loop: nothing is pumping a queue that a dispatcher could post to. Swing and JavaFX supply dispatchers because they run such a loop. A headless test process has no loop, so there is nothing for Main to mean — which reframes the whole thing. The desktop target was not misconfigured. It was being asked for something that does not exist there.

Which makes Dispatchers.Main in commonMain a design error. The shared ViewModel does not know what thread its consumer wants, and on one target the answer is "there isn't one". A dispatcher is a property of the host, so it belongs in the host's wiring, injected in — which is also what makes the code testable without a rule 340 tests have to remember.

The four supply mechanisms for the main dispatcher measured per target, with three having an event loop and a bare JVM having a thread named main that nothing pumps

The fix

The scope takes its dispatcher from outside, each platform supplies its own, and the test target supplies the test one — so nothing in shared code names a thread.

// core/common/src/commonMain/kotlin/BaseViewModel.kt
//
// The dispatcher is INJECTED, not named. Dispatchers.Main is supplied by a platform
// integration, and a headless JVM has no integration to supply it — not because it is
// misconfigured but because there is no event loop for Main to mean anything. Shared code
// naming Main was asserting a fact about every host, and on one host the fact is false.
abstract class BaseViewModel(mainDispatcher: CoroutineDispatcher) {
    protected val scope = CoroutineScope(SupervisorJob() + mainDispatcher)
    fun clear() = scope.cancel()
}
// core/common/src/commonMain/kotlin/AppDispatchers.kt
/**
 * The host declares its threads. `main` is the one with a platform answer and a platform
 * ABSENCE — see BaseViewModel. `io` and `default` come from core on every target.
 */
interface AppDispatchers {
    val main: CoroutineDispatcher
    val io: CoroutineDispatcher
    val default: CoroutineDispatcher
}
// androidMain
class AndroidDispatchers : AppDispatchers {
    // .immediate so a state write already on the main thread is not posted — the behaviour
    // the original Dispatchers.Main.immediate was there for, kept.
    override val main = Dispatchers.Main.immediate
    override val io = Dispatchers.IO
    override val default = Dispatchers.Default
}

// iosMain — Main is compiled into core's Darwin source set, so this needs no extra artifact
class IosDispatchers : AppDispatchers {
    override val main = Dispatchers.Main
    override val io = Dispatchers.IO
    override val default = Dispatchers.Default
}

// desktopMain — the SHIPPED desktop app, which does have an event loop
class DesktopDispatchers : AppDispatchers {
    // Swing here is correct: the Compose Desktop app runs an AWT event loop, so there IS a
    // main thread to dispatch to. The artifact is scoped to the app module, never to :core.
    override val main = Dispatchers.Swing
    override val io = Dispatchers.IO
    override val default = Dispatchers.Default
}
// commonTest — and the test target needs no Main dispatcher at all
class TestDispatchers(private val d: TestDispatcher) : AppDispatchers {
    override val main = d
    override val io = d
    override val default = d
}

And the rule that stops the next one, because the original mistake is easy to re-make and the failure is 340 tests later:

// build-logic/src/main/kotlin/DispatcherLint.kt — wired into :check
val forbidMainInShared by tasks.registering {
    val shared = fileTree("src/commonMain/kotlin") { include("**/*.kt") }
    // Captured HERE, at configuration time. Reading `projectDir` inside doLast captures the
    // Project and makes the task configuration-cache incompatible — which disables the cache
    // for the whole build, a steep price for a lint rule.
    val root = projectDir
    doLast {
        val hits = shared.filter { f ->
            f.readText().contains("Dispatchers.Main")
        }.map { it.relativeTo(root).path }
        if (hits.isNotEmpty()) {
            throw GradleException(
                "Dispatchers.Main in commonMain: $hits\n" +
                "  Main is supplied by a platform integration and is ABSENT on a bare JVM. " +
                "Inject AppDispatchers instead — see core/common/AppDispatchers.kt."
            )
        }
    }
}

And the output it produced:

$ ./gradlew :core:common:desktopTest :feature:profile:desktopTest
BUILD SUCCESSFUL
340 tests, 340 passed

$ git stash && ./gradlew check          # the rule against the original code
> Task :core:common:forbidMainInShared
FAILURE: Dispatchers.Main in commonMain: [kotlin/BaseViewModel.kt]

$ git stash pop && ./gradlew check      # and against the injected version
> Task :core:common:forbidMainInShared
BUILD SUCCESSFUL

$ # and the test task no longer starts an event thread
  :core:common:desktopTest   startup   before 1.8s (EDT)   after 0.0s
  JVM exits cleanly                     no                  yes

The injected version also removed the Dispatchers.setMain rule from every test:

  tests carrying a setMain/resetMain rule
    before   340
    after      0

Which matters beyond tidiness: the rule was the thing three new tests forgot.

How we proved it

That Main is absent rather than broken on a bare JVM — the measurement that reframed the fix, because "missing module" reads as a packaging problem:

$ # a one-file probe per target, printing what Main resolves to
  android               Dispatchers.Main → MainDispatcher (android)
  iosSimulatorArm64     Dispatchers.Main → MainDispatcher (Darwin)
  desktop, bare         Dispatchers.Main → MissingMainCoroutineDispatcher
  desktop, + swing      Dispatchers.Main → SwingDispatcher

Three of four have an event loop. The fourth has a thread named main and nothing pumping it.

That nothing fails earlier than runtime. Checked deliberately, since a compile or link error would have made this a ten-minute problem:

  stage                                     signal
  compileKotlinDesktop                      none
  :desktopTest compilation                  none
  first construction of a ViewModel         IllegalStateException

That the Swing fix has a cost in a headless process, which is what ruled it out for :core:

  configuration           desktopTest startup    non-daemon threads after the last test
  bare (broken)           —                      —
  + coroutines-swing      +1.8s                  1  (AWT-EventQueue-0)
  + swing, -Dheadless     +1.8s                  0

That ServiceLoader is the Android supply path, verified because it explains a release-only variant of the same crash we had seen months earlier and never connected:

$ unzip -l app-release.apk | grep MainDispatcherFactory
  (nothing)
$ unzip -l app-debug.apk | grep MainDispatcherFactory
  META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory

Same exception, release only, caused by a shrink rule rather than by a missing dependency — and invisible to anything that tests debug builds.

That the injected dispatcher preserves .immediate semantics. The behaviour we did not want to lose, asserted rather than assumed:

$ ./gradlew :core:common:allTests --tests '*StateWriteOnMainThreadIsNotPosted*'
  androidUnitTest        PASSED   (no re-post observed)
  desktopTest            PASSED   (TestDispatcher, trivially immediate)
  iosSimulatorArm64Test  PASSED

What I cannot tell you is whether Dispatchers.Main without .immediate would have been materially different on iOS. On the coroutines version we shipped, the Darwin Main did not give us a usable immediate, so the iOS actual posts where Android does not — and that is a statement about one version rather than about the platform, since MainCoroutineDispatcher.immediate has moved between releases. We have not measured a case where the ordering difference is visible to a user. It is a real behavioural difference between targets that our tests do not distinguish.

And an honest consequence. Every ViewModel constructor now takes an AppDispatchers, which appears in 34 constructors and in every DI module. That is real ceremony added to make a thread choice explicit, and a reader who has only ever shipped Android will reasonably find it noise — right up to the first headless test run.

Three measurements: nothing failing before runtime, the Swing workaround adding startup cost and a lingering non-daemon thread, and the same exception appearing in release builds through a shrink rule

What this generalizes to

Some symbols exist on every target and are implemented on only some of them, resolved at first use rather than at link time. They look portable because they compile everywhere, and the failure lands in whichever target lacks the supplier — usually the one that is least like a phone, which is often the one running most of your tests. The useful question when such a thing breaks is not "what is missing on this target" but "does the concept exist here at all": when the answer is no, the fix is to stop shared code from asserting it, not to manufacture a supplier.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS