Skip to content

[Bug]: appfunctions-compiler: KaInvalidLifetimeOwnerAccessException ("PSI has changed since creation") in AppFunctionAggregateProcessor on every incremental KSP2 build #11

@willhou

Description

@willhou

Is there an existing issue for this?

  • I have searched the existing issues

Is there a StackOverflow question about this issue?

  • I have searched StackOverflow

What happened?

Component / versions

  • androidx.appfunctions / appfunctions-compiler: 1.0.0-alpha09 (also reproduces on 1.0.0-alpha08)
  • KSP: 2.3.8 (KSP2; KSP1 is no longer available in this release, so ksp.useKSP2=false is not an escape hatch)
  • Kotlin: 2.3.21
  • AGP: 9.2.1, Gradle: 9.5.1
  • compileSdk 37, targetSdk 36
  • Multi-module project; all @AppFunction / @AppFunctionSerializable sources and the aggregation live in the application module (:app), which also runs Hilt, Room, Glide, and Navigation SafeArgs through KSP. ksp { arg("appfunctions:aggregateAppFunctions", "true") } is set on :app only.

Summary

With incremental KSP enabled (the default), every run of the app module's KSP task fails in the AppFunctions aggregation round — including the first run from a fully pristine state (build/kspCaches and build/generated/ksp deleted):

e: [ksp] ksp.org.jetbrains.kotlin.analysis.api.lifetime.KaInvalidLifetimeOwnerAccessException:
Access to invalid ksp.org.jetbrains.kotlin.analysis.api.platform.lifetime.KotlinAlwaysAccessibleLifetimeToken@5a369373:
PSI has changed since creation

> Task :app:kspDefaultLocalDebugKotlin FAILED

Setting ksp.incremental=false in gradle.properties reliably avoids it, at the cost of full KSP reprocessing for every processor in the module (Hilt/Room/Glide included). The failure is deterministic in our module; we controlled for plausible confounders:

  • Reproduces identically with a pristine KSP state (no kspCaches, no prior generated outputs) — so it is not stale-state corruption from earlier builds; the invalid PSI handle arises within a single multi-round run.
  • Reproduces identically after removing our own custom KSP processor from the module — not an interaction with third-party processors.
  • We could not reproduce it in a minimal two-module sample (appfunctions + Hilt + a custom processor + 300 filler classes); the trigger appears to depend on module scale or generated-file/round count. Happy to provide further diagnostics from the failing module on request.

Steps to reproduce

Deterministic in our app module (single aggregating module; several thousand Kotlin sources; Hilt, Glide, and a custom processor running alongside the appfunctions compiler):

  1. ksp.incremental unset (default true), ksp { arg("appfunctions:aggregateAppFunctions", "true") }.
  2. ./gradlew :app:kspDebugKotlin → fails with the exception above, on first runs and rebuilds alike.
  3. Set ksp.incremental=false → always succeeds.

Analysis

The stack trace (attached, captured with --stacktrace) shows the crash fires inside the aggregation round's createNewFile(Dependencies.ALL_FILES, …) call — the line marked // TODO: Collect all AppFunction files as source files set at androidx-main commit a7958234146a3189a640b777ba9f113303d737db:

Caused by: ksp.org.jetbrains.kotlin.analysis.api.lifetime.KaInvalidLifetimeOwnerAccessException:
        Access to invalid ...KotlinAlwaysAccessibleLifetimeToken@3d687a88: PSI has changed since creation
    at ksp.org.jetbrains.kotlin.analysis.api.fir.symbols.KaFirFileSymbol.getPsi(KaFirFileSymbol.kt:65)
    at com.google.devtools.ksp.impl.symbol.kotlin.KSFileImpl.getPsi(KSFileImpl.kt:46)
    at com.google.devtools.ksp.impl.symbol.kotlin.KSFileImpl.getFilePath(KSFileImpl.kt:63)
    at ksp.com.google.devtools.ksp.common.impl.CodeGeneratorImpl.associate(CodeGeneratorImpl.kt:200)
    at ksp.com.google.devtools.ksp.common.impl.CodeGeneratorImpl.createNewFile(CodeGeneratorImpl.kt:171)
    at androidx.appfunctions.compiler.processors.AppFunctionAggregateProcessor.generateAggregatedAppFunctionInventory(AppFunctionAggregateProcessor.kt:98)
    at androidx.appfunctions.compiler.processors.AppFunctionAggregateProcessor.process(AppFunctionAggregateProcessor.kt:73)

Putting the pieces together:

  1. AppFunctionAggregateProcessor deliberately defers to a late roundshouldProcess() returns true only once resolveAnnotatedAppFunctions() is empty, i.e. after earlier rounds have generated the per-class inventories/invokers/registry classes (AppFunctionAggregateProcessor.kt:191-195). By then, the rounds in between have generated many files (in our module: appfunctions per-class output plus Hilt and other processors).
  2. In that round, createNewFile(Dependencies.ALL_FILES, …) makes KSP's CodeGeneratorImpl.associate walk every KSFile to record the new file's origins — this origin bookkeeping only happens in incremental mode, which is consistent with ksp.incremental=false avoiding the crash entirely (the walk never runs) rather than the staleness not existing. Some of the walked KSFileImpl handles refer to PSI invalidated by the generation that happened in the rounds since the handles were created, and the first getFilePath on a stale handle trips the Analysis API lifetime-token check.

Supporting data points:

Suggested directions

  • Fix the Dependencies.ALL_FILES TODOs in generateAggregatedAppFunctionInventory / generateAggregatedAppFunctionInvoker (this is the crashing call): pass the actual originating KSFiles of the discovered components (or Dependencies(aggregating = true) with the real sources) instead of ALL_FILES. Besides being the crash site, ALL_FILES also forces the aggregated outputs to regenerate on every incremental build.
  • The discovery path has the same hazard one layer down: AppFunctionSymbolResolver.filterAppFunctionComponent uses resolver.getDeclarationsFromPackage(...) (@KspExperimental) plus getClassDeclarationByName to re-resolve classes generated earlier in the same compilation (AppFunctionSymbolResolver.kt:352-356, :309-332). Discovering current-module components via getSymbolsWithAnnotation(@AppFunctionComponentRegistry) — they already carry that annotation — or passing qualified names in memory from the per-class processors (same provider instance) would avoid re-resolving freshly generated PSI; the package scan would then only be needed for classpath (AAR) components, which cannot change during compilation.

Workarounds

  • ksp.incremental=false in gradle.properties. Works reliably, but disables incremental KSP for every processor in the module (Hilt, Room, Glide included).
  • Keeping all @AppFunction sources in library modules so the aggregating app module runs shouldProcess()/aggregation in round 1 (verified above) — a significant restructuring for apps that, like ours, started with functions in the app module.
  • Not workarounds: ksp.useKSP2=false is rejected by KSP 2.3.8 ("KSP1 is no longer available"), and aggregateAppFunctions=false avoids the crash only by disabling inventory/invoker/index-XML generation entirely.

Full --stacktrace output attached.

Relevant logcat output

Code of Conduct

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions