skip to content

Explain KSP's multi-round model: why does process() return a List<KSAnnotated>, and how does deferral interact with code that is generated during processing?

level: principalimportance: should knowfreq 28%

answer

  1. Rounds loop until no new files + nothing deferred
  2. Return = symbols to retry next round (usually !validate())
  3. Generated code can be input to later rounds
  4. Guard duplicate files + non-termination
  5. finish() runs once at the end; no Resolver there

basics

~20 s

KSP runs process() several times (rounds). If a symbol can't be handled yet because the types it needs don't exist yet, you return it so KSP tries again next round, after more code has been generated.

solid answer

~50 s

KSP processes in **rounds**. process(resolver) returns the symbols it could not finish — typically those failing validate() because they reference declarations not yet generated. KSP collects newly generated files, starts another round, and re-invokes process(), now also surfacing the previously deferred symbols (and symbols whose annotations exist on the freshly generated code). This continues until a round produces no new files and no deferred symbols. Important nuances: getSymbolsWithAnnotation in a later round returns symbols valid in that round including those tied to generated code; deferred symbols you return are passed back. The processor must be idempotent — guard against re-emitting a file (FileAlreadyExistsException) and against infinite loops if you keep deferring without progress. finish() is called once after the last round for cleanup. Within a round, resolve() reflects the program state for that round only.

code

kotlin · 20 lines
kotlin
class WireProcessor(
    private val codeGenerator: CodeGenerator,
    private val logger: KSPLogger,
) : SymbolProcessor {
    private val handled = mutableSetOf<String>()  // cross-round bookkeeping

    override fun process(resolver: Resolver): List<KSAnnotated> {
        val symbols = resolver.getSymbolsWithAnnotation("com.example.Wire").toList()
        val (ready, deferred) = symbols.partition { it.validate() }
        ready.filterIsInstance<KSClassDeclaration>().forEach { decl ->
            val name = decl.qualifiedName?.asString() ?: return@forEach
            if (handled.add(name)) emit(decl)   // emit at most once
        }
        return deferred
    }

    override fun finish() { logger.info("Generated ${handled.size} wirings") }

    private fun emit(decl: KSClassDeclaration) { /* createNewFile ... */ }
}

go deeper

for a junior

Knows process() can run more than once and that you return symbols you couldn't handle yet.

for a middle

Explains deferral via validate() and that generated code can be reprocessed in later rounds.

for a senior

Describes the fixed-point loop, finish(), and the duplicate-file/idempotence hazard concretely.

for a principal

Reasons about termination guarantees, cross-round state, determinism for caching/incrementality, and aggregating-dependency interaction with rounds.

## Why rounds exist Generated code can itself be **input** to processing: a class you emit may carry annotations, or another annotated class may *reference* a type you haven't generated yet. A single pass can't handle that ordering, so KSP runs **multiple rounds**, calling `process()` once per round until the system reaches a fixed point. ## The return value: deferral ```kotlin override fun process(resolver: Resolver): List<KSAnnotated> ``` The returned list is the set of symbols the processor **could not complete this round** and wants KSP to **re-present next round**. The canonical reason is failed **`validate()`**: a symbol references a type that isn't resolvable yet (often because it will be generated by this or another processor in a later round). ```kotlin override fun process(resolver: Resolver): List<KSAnnotated> { val symbols = resolver.getSymbolsWithAnnotation("com.example.Wire").toList() val (ready, deferred) = symbols.partition { it.validate() } ready.filterIsInstance<KSClassDeclaration>().forEach { emit(it) } return deferred // KSP re-invokes process() with these next round } ``` ## The round loop, precisely 1. **Round 1**: `process()` runs over the original sources. You emit files and return deferred symbols. 2. KSP gathers the files generated this round and makes them part of the program. 3. **Round N+1**: `process()` runs again. Now `getSymbolsWithAnnotation` can also return symbols **on the newly generated code**, and the deferred symbols from round N are available again. 4. The loop stops when a round generates **no new files** and there is **nothing left deferred** — a fixed point. 5. **`finish()`** (optional override) is invoked **once** after the final round for cleanup/aggregation that needs the complete picture. There's no `Resolver` in `finish()` because processing is over. ## Hazards a principal must control - **Idempotence / duplicate files** — emitting the same `packageName`+`fileName` twice (e.g., re-processing the same symbol in two rounds) throws `FileAlreadyExistsException`. Track what you've already generated, or only generate from symbols you handle exactly once. - **Non-termination** — if you defer a symbol every round without ever making progress, the build can loop or fail. Defer **only** when there's a real chance later rounds will resolve it; otherwise report an error via `KSPLogger.error` and stop deferring. - **Determinism** — generated output should be stable given the same inputs (sorted iteration, no reliance on `HashSet` order) so incremental builds and caches behave. - **State across rounds** — a processor instance is reused across rounds within one compilation, so instance fields persist; use that for cross-round bookkeeping, but reset appropriately between compilations. - **Aggregating dependencies** interact with rounds: outputs summarizing all matches must use `Dependencies(aggregating = true, ...)` so new symbols appearing in later rounds invalidate the summary. ## Worked deferral scenario Suppose `@Wire class A(val b: B)` where `B` is generated by another `@Wire`-driven processor. In round 1, `A`'s parameter type `B` may not resolve → `A.validate()` is false → defer `A`. After `B` is generated, round 2 resolves `B`, `A.validate()` is true, and you emit `A`'s output. ## Key terms - **Round** — one `process()` invocation; KSP loops to a fixed point. - **Deferral** — returning unfinished `KSAnnotated` for a later round. - **validate()** — resolvability check that usually drives deferral. - **finish()** — single post-processing callback after the last round. - **Idempotence** — not re-emitting files across rounds.

  • What stops KSP's round loop?
    It reaches a fixed point: a round in which no new files are generated and the processor returns no deferred symbols. finish() is then called once.
  • A symbol keeps failing validate() forever. What should the processor do?
    Stop deferring it indefinitely. After it's clear the referenced type will never be generated, report a real error with KSPLogger.error (failing the build with a clear message) instead of looping, to avoid non-termination and confusing builds.
  • Why must generation be idempotent across rounds?
    Because the same symbol can be seen in multiple rounds; re-emitting the same package+file name throws FileAlreadyExistsException. Track generated names (or generate strictly once per symbol) to stay idempotent.

Rounds are like assembling flat-pack furniture: some pieces (symbols) can't be attached until others exist; you set them aside (defer) and come back once the prerequisite parts are built, repeating until nothing is left unattached.

saying these in an interview costs you the question

  • Thinking process() runs exactly once
  • Deferring symbols with no path to resolution (infinite loop)
  • Re-emitting the same file each round and crashing
  • Expecting a Resolver inside finish()
  • Relying on non-deterministic iteration order in generated output

context