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?
answer
- Rounds loop until no new files + nothing deferred
- Return = symbols to retry next round (usually !validate())
- Generated code can be input to later rounds
- Guard duplicate files + non-termination
- finish() runs once at the end; no Resolver there
basics
~20 sKSP 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 sKSP 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 linesclass 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
Knows process() can run more than once and that you return symbols you couldn't handle yet.
Explains deferral via validate() and that generated code can be reprocessed in later rounds.
Describes the fixed-point loop, finish(), and the duplicate-file/idempotence hazard concretely.
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