Engineering

KSP runs once per target, and your generated code can disagree with itself

A processor sees one compilation at a time. Generating into commonMain is a race you win by accident.

MobileByteSensei 8 min read
kmpkspbuild

TL;DR

KSP is a compiler plugin, and a Kotlin Multiplatform module has one compilation per target. So the processor runs several times — once for androidDebug, once for iosArm64, once for wasmJs — each seeing only the sources that compilation sees.

Two consequences. A processor that aggregates annotations produces a different registry per target, because androidMain annotations are invisible to the iOS run. And generating into a shared source directory means several tasks writing the same file, which is a Gradle configuration error at best and a build-order-dependent result at worst.

Generate per target. Aggregate in commonMain by hand, or make the processor's output target-local and have shared code consume it through an expect.

The case this post is about

A tiny annotation processor that builds a registry of screens, so navigation does not need a hand-maintained list:

// commonMain
@Screen("list")   class ListScreen
@Screen("detail") class DetailScreen

// androidMain — an Android-only screen
@Screen("settings_android") class AndroidSettingsScreen

The processor collects every @Screen and emits a registry:

// generated
object ScreenRegistry {
    val all = listOf("list", "detail", "settings_android")
}

Wired the way the KSP docs show for a single-platform project:

// build.gradle.kts
dependencies {
    add("kspCommonMainMetadata", project(":screen-processor"))
}

It worked. Then the iOS build produced a registry missing a screen, and the desktop build produced a different one again.

The symptom

Three distinct failures.

A different registry per target. The same ScreenRegistry.all, three values:

androidDebug   ["list", "detail", "settings_android"]
iosArm64       ["list", "detail"]
wasmJs         ["list", "detail"]

Correct, in fact — settings_android genuinely does not exist on iOS. But shared code iterating ScreenRegistry.all had no idea the contents varied, so a shared screen-count assertion passed on Android and failed elsewhere.

Gradle refused the shared output directory. The attempt to make all targets see one registry:

Execution failed for task ':shared:compileKotlinIosArm64'.
> Entry generated/ksp/ScreenRegistry.kt is a duplicate but no duplicate handling strategy has been set.

Several KSP tasks writing one path. Gradle is right to stop.

And a stale registry that depended on build order. Before the duplicate error surfaced, with output wired into a shared source directory, the registry contents depended on which compilation ran last:

./gradlew build                        → registry had 3 entries
./gradlew clean compileKotlinIosArm64  → registry had 2 entries
./gradlew build                        → 3 again

A build result that changes with task order is the worst of the three, because it is invisible until it is not.

Why the obvious fix failed

"Use kspCommonMainMetadata and be done." That configuration generates for the metadata compilation, which is what commonMain typechecks against. It does not make the output available to the per-target compilations without extra wiring, and it only sees commonMain sources — so the Android-only screen is invisible to it by design.

"Add the generated directory to commonMain.kotlin.srcDir." This is where the build-order dependence came from. Several tasks write there; whichever ran last won. It also creates a task dependency cycle risk that Gradle will eventually complain about.

"Register the processor for every target configuration." Correct, and on its own it produces the three-different-registries result — which is the real behaviour and needs to be handled rather than hidden.

"duplicatesStrategy = EXCLUDE." Silences the error and picks an arbitrary winner. This is the fix that turns a loud build failure into the silent build-order bug, which is strictly worse.

The actual mechanism

Three facts compose into the problem.

A KMP module has one Kotlin compilation per target — plus a metadata compilation for common code. compileKotlinIosArm64, compileDebugKotlinAndroid, compileKotlinWasmJs are separate tasks with separate inputs.

KSP attaches to a compilation. It is given that compilation's source set closure and nothing else. For iosArm64 that is iosArm64Main + iosMain + appleMain + nativeMain + commonMain — and specifically not androidMain.

Each run has its own output directory. build/generated/ksp/<target>/…, which is correct and is why a shared directory is a conflict.

So "aggregate every annotation in the module" is not a thing a single KSP run can do. Each run aggregates its own view, and the views genuinely differ.

There is a fifth compilation, and it is the one people reach for when they learn this. kspCommonMainMetadata processes commonMain alone — one run, one view, no per-target divergence, which sounds exactly like the fix. It has two properties that decide whether it is:

dependencies { add("kspCommonMainMetadata", project(":processor")) }

kotlin.sourceSets.commonMain {
    // Without this, the generated code exists on disk and no compilation can see it.
    kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}

// And every per-target compilation must wait for it, or the first build after a clean
// races: the Android compile starts before the generator has written anything.
tasks.withType<KotlinCompile<*>>().configureEach {
    if (name != "kspCommonMainKotlinMetadata") dependsOn("kspCommonMainKotlinMetadata")
}

The srcDir line is the one that bites, because omitting it fails in the least informative way available: KSP runs, reports success, writes the file, and the build then fails on an unresolved reference to a class you can see in build/generated.

And the limit worth being explicit about: this run sees commonMain only. Annotations in androidMain are invisible to it — which is correct for a registry of shared types and is exactly the wrong tool if what you are aggregating is per-platform implementations. That distinction is what decides between this and the aggregation strategy below, and it is a property of your annotations rather than of KSP.

One module with four Kotlin compilations, each running the symbol processor over only its own source set closure: the Android run sees androidMain and generates a three-entry registry, the iOS and wasm runs cannot see androidMain and generate two entries, and pointing all four at one shared output directory makes the result depend on which task ran last

A processor's output is scoped to the compilation that produced it. Per-target generation is not a limitation to route around — it is the only thing that can be correct when the inputs differ per target.

The fix

Generate per target, consume through a declared shared contract, and let the per-target difference be explicit rather than accidental.

// build.gradle.kts — one configuration per target, each with its own output
dependencies {
    add("kspAndroidDebug",        project(":screen-processor"))
    add("kspAndroidRelease",      project(":screen-processor"))
    add("kspIosArm64",            project(":screen-processor"))
    add("kspIosSimulatorArm64",   project(":screen-processor"))
    add("kspWasmJs",              project(":screen-processor"))
    add("kspDesktop",             project(":screen-processor"))
}

The processor emits a target-local object implementing a shared interface:

// commonMain — hand-written. The contract the generated code satisfies.
interface ScreenRegistry {
    val all: List<String>
}

expect val screenRegistry: ScreenRegistry
// generated, per target — e.g. build/generated/ksp/iosArm64/…/GeneratedScreenRegistry.kt
internal object GeneratedScreenRegistry : ScreenRegistry {
    override val all = listOf("list", "detail")
}

actual val screenRegistry: ScreenRegistry get() = GeneratedScreenRegistry

Three properties this buys:

No shared output directory, so no duplicate and no build-order dependence. Each compilation reads its own generated file.

The variation is declared. expect val screenRegistry makes it structurally obvious that each target supplies its own, which is the honest description of what was happening anyway.

Shared code can still assert the invariants that must hold everywhere:

// commonTest — runs on every target, asserting the PORTABLE subset
private val CORE_SCREENS = setOf("list", "detail")

@Test
fun everyTargetRegistersTheCoreScreens() {
    assertTrue(screenRegistry.all.containsAll(CORE_SCREENS))
}

@Test
fun noDuplicateRoutes() {
    assertEquals(screenRegistry.all.size, screenRegistry.all.distinct().size)
}

That first test is what the broken shared-count assertion should have been: assert the screens that must exist, not a total that legitimately varies.

For the genuinely cross-target aggregate, if you need one, build it in common code from the per-target registries rather than from a processor:

// commonMain — the union, for a docs page or an analytics schema
val knownRoutes: Set<String> = CORE_SCREENS + PLATFORM_EXTRA_ROUTES

With PLATFORM_EXTRA_ROUTES an expect val — one more declared difference instead of a hidden one.

And one Gradle detail worth knowing, because it is the part the docs do not emphasise: the metadata compilation needs its own handling if commonMain itself must see generated code at typecheck time. The clean way is for commonMain to see only hand-written declarations (the interface and the expect), which is what the design above does — it sidesteps the metadata problem entirely rather than configuring around it.

How we proved it

That each target gets its own correct registry:

$ ./gradlew clean build
$ find build/generated/ksp -name 'GeneratedScreenRegistry.kt' | while read f; do
    echo "$f: $(grep -o 'listOf(.*)' "$f")"
  done

…/ksp/androidDebug/…:  listOf("list", "detail", "settings_android")
…/ksp/iosArm64/…:      listOf("list", "detail")
…/ksp/wasmJs/…:        listOf("list", "detail")
…/ksp/desktop/…:       listOf("list", "detail")

Reading the generated files directly is the check, not inferring from behaviour.

That the result no longer depends on build order — the regression test for the worst symptom:

$ ./gradlew clean compileKotlinIosArm64 && grep -c settings_android <iosArm64 generated>
0
$ ./gradlew build && grep -c settings_android <iosArm64 generated>
0
$ ./gradlew clean build && grep -c settings_android <iosArm64 generated>
0

Three different orders, same answer. Previously this sequence produced 2, 3, 3.

That the portable invariants hold on every target:

./gradlew allTests
  jvmTest(desktop)       everyTargetRegistersTheCoreScreens  PASSED
  androidUnitTest        everyTargetRegistersTheCoreScreens  PASSED
  iosSimulatorArm64Test  everyTargetRegistersTheCoreScreens  PASSED
  wasmJsTest             everyTargetRegistersTheCoreScreens  PASSED

And that a missing registration fails — because a registry test that cannot fail is decoration:

// remove @Screen from DetailScreen
iosSimulatorArm64Test  everyTargetRegistersTheCoreScreens  FAILED
  expected to contain "detail"

One limit: this assumes each target's registry is independently valid, which is true for screens and not for everything. A processor generating something that must be globally consistent — a single ID-to-type mapping where the numbers must match across targets — cannot be solved this way; that needs the aggregate to be hand-written in commonMain, with the per-target processors verifying it rather than producing it.

What this generalizes to

A code generator's scope is its compilation unit's scope. Annotation processors, macro expanders, schema generators — each sees one unit. "Collect everything in the project" is usually not expressible, and designs that assume it break the first time the project has more than one unit.

Several writers to one output is a bug even when it appears to work. The build-order-dependent registry is the shape to fear: correct most of the time, wrong after a particular sequence, and with nothing in the output indicating which you got. A duplicate-file error is a gift.

duplicatesStrategy and friends convert loud failures into quiet ones. Any flag whose effect is "stop telling me about the conflict" deserves suspicion. The conflict was information.

Assert the invariant subset, not the total. The shared test that failed was asserting a count that legitimately varies. When a value differs per environment, find the part that must not and assert that — a total is rarely the invariant you actually care about.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

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

Follow by RSS