Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import com.itsaky.androidide.lsp.models.DocumentChange
import com.itsaky.androidide.lsp.models.TextEdit
import com.itsaky.androidide.resources.R
import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol
import org.jetbrains.kotlin.analysis.api.fir.diagnostics.KaFirDiagnostic
import org.slf4j.LoggerFactory

class AddImportAction : BaseKotlinCodeAction() {
Expand Down Expand Up @@ -46,14 +45,13 @@ class AddImportAction : BaseKotlinCodeAction() {
return
}

val diagnostic = extra.diagnostic as? KaFirDiagnostic.UnresolvedReference?
if (diagnostic == null) {
val reference = extra.unresolvedReference
if (reference == null) {
markInvisible()
return
}

val env = extra.compilationEnv
val reference = diagnostic.reference
val hasImportableSymbols = env.ktSymbolIndex
.findSymbolBySimpleName(reference, limit = 0)
.any { it.kind.isClassifier }
Expand All @@ -65,10 +63,10 @@ class AddImportAction : BaseKotlinCodeAction() {
}

override suspend fun execAction(data: ActionData): Map<JvmSymbol, List<TextEdit>> {
val (diagnostic, env) = data.require<DiagnosticItem>().extra as? KotlinDiagnosticExtra
val (reference, env) = data.require<DiagnosticItem>().extra as? KotlinDiagnosticExtra
?: return emptyMap()

diagnostic as KaFirDiagnostic.UnresolvedReference
if (reference == null) return emptyMap()

val file = data.requireFile()
val nioPath = file.toPath()
Expand All @@ -77,7 +75,7 @@ class AddImportAction : BaseKotlinCodeAction() {
?: return emptyMap()

return env.ktSymbolIndex
.findSymbolBySimpleName(diagnostic.reference, limit = 0)
.findSymbolBySimpleName(reference, limit = 0)
.filter { it.kind.isClassifier }
.associateWith { symbol -> insertImport(ktFile, symbol.fqName) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,22 +208,24 @@ internal class CompilationEnvironment(
@OptIn(KaImplementationDetail::class)
private inline fun notifyElementModifiedForPath(
path: Path,
typeProvider: (KtFile) -> KaElementModificationType,
crossinline typeProvider: (KtFile) -> KaElementModificationType,
) {
val structureProvider = ProjectStructureProvider.getInstance(project)
val ktFile = path.toVirtualFileOrNull()?.let {
psiManager.findFile(it) as? KtFile
}

if (ktFile != null) {
KaSourceModificationService.getInstance(project)
.handleElementModification(ktFile, typeProvider(ktFile))
}

val module = (ktFile?.let { structureProvider.getModule(it, null) }
?: structureProvider.findModuleForSourceId(path.pathString)) as? AbstractKtModule
Comment on lines 213 to 219

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how psiManager.findFile / getModule are guarded elsewhere in this module.
rg -nP -C4 '\b(psiManager\.findFile|structureProvider\.getModule|findModuleForSourceId)\b' \
  lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin
# Show the project.read/write helper contracts.
fd -t f 'ReadWriteLock.kt' lsp/kotlin/src/main/java --exec cat -n {}

Repository: appdevforall/CodeOnTheGo

Length of output: 13091


🏁 Script executed:

#!/bin/bash
# Get the full method context around lines 213-219
sed -n '200,230p' lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt

# Check if this is inside any existing read/write lock wrapper
sed -n '205,225p' lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt | cat -n

Repository: appdevforall/CodeOnTheGo

Length of output: 2228


🏁 Script executed:

#!/bin/bash
# Search for other usages of psiManager.findFile to see how they're typically guarded
rg -nB2 -A2 'psiManager\.findFile' lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin --type kt | head -50

Repository: appdevforall/CodeOnTheGo

Length of output: 189


🏁 Script executed:

#!/bin/bash
# Check the method signature and find if there's a wrapping read lock
sed -n '205,230p' lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt

Repository: appdevforall/CodeOnTheGo

Length of output: 1133


Wrap PSI/module resolution in a read lock. The calls to psiManager.findFile(...) and structureProvider.getModule(...)/findModuleForSourceId(...) at lines 214-219 run without protection, while the explicit comment at line 222-223 acknowledges that concurrent analyze operations holding read locks can race against the subsequent write mutation. Compare with loadKtFile() (line 298), which correctly wraps psiManager.findFile() in project.read { }. Protect these PSI/module-structure reads by wrapping lines 213-219 in project.read { ... }.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt`
around lines 213 - 219, The PSI and module resolution calls in the code block
(involving psiManager.findFile, structureProvider.getModule, and
findModuleForSourceId) are executing without read lock protection, creating a
race condition vulnerability since concurrent analyze operations hold read
locks. Wrap the entire code block from structureProvider.getInstance through the
module assignment in a project.read block, following the same pattern used in
the loadKtFile method which correctly protects its psiManager.findFile call with
project.read.


project.write {
// Must run under the write lock so the session mutation can't race a concurrent
// `analyze` (which only holds the read lock); see onFileContentChanged.
if (ktFile != null) {
KaSourceModificationService.getInstance(project)
.handleElementModification(ktFile, typeProvider(ktFile))
}

if (module != null) {
module.invalidateSearchScope()
project.publishModificationEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,46 @@ import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.UserDataProperty
import java.nio.file.Path
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock

private val KT_LSP_COMPLETION_BACKING_FILE = Key<Path>("KT_LSP_COMPLETION_BACKING_FILE")
var KtFile.backingFilePath by UserDataProperty(KT_LSP_COMPLETION_BACKING_FILE)

internal inline fun <R> analyzeMaybeDangling(useSiteElement: KtElement, crossinline action: KaSession.() -> R): R {
if (useSiteElement is KtFile && useSiteElement.isDangling && useSiteElement.copyOrigin != null) {
return analyzeCopy(useSiteElement, KaDanglingFileResolutionMode.PREFER_SELF, action)
}
/**
* Serializes all Kotlin Analysis API access (`analyze` / `analyzeCopy`).
*
* The Analysis API tracks its `analyze` lifetime context in a per-thread stack and is not safe to
* drive concurrently from multiple background threads without the platform read-action coordination
* that this LSP replaces with a custom [com.itsaky.androidide.lsp.kotlin.compiler.read] lock.
* Indexing, diagnostics and completion all run analysis on `Dispatchers.Default` and frequently
* target the same edited file, so overlapping `analyze` calls corrupted the lifetime/session
* lifecycle and surfaced as
* `KaInaccessibleLifetimeOwnerAccessException: ... Called outside an \`analyze\` context.`
*
* Holding this lock around every analysis entry point makes analyses mutually exclusive. It is a
* [ReentrantLock] so an (indirect) nested analysis on the same thread cannot deadlock.
*
* **Footgun:** analysis runs under the *read* (shared) side of the global
* [com.itsaky.androidide.lsp.kotlin.compiler.read] lock, and that `ReentrantReadWriteLock` is
* non-upgradeable. Code running inside [withAnalysisLock] / an `analyze` block must therefore never
* call [com.itsaky.androidide.lsp.kotlin.compiler.write] — upgrading read → write on the same thread
* deadlocks.
*/
private val analysisLock = ReentrantLock()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Altitude — second global lock overlaps the existing one. ReadWriteLock.kt's lock is already a single top-level ReentrantReadWriteLock shared into every project (verified), i.e. process-global. The root cause is that analyze runs under its read (shared) side. Running analysis under an exclusive section of that existing lock (or a per-CompilationEnvironment analysis lock) would fix concurrent-analyze without introducing a second global lock to reason about. Footgun: with two locks, any future analyze block that calls project.write while holding analysisLock + read would self-deadlock (the RW lock is non-upgradeable).


/**
* Runs [action] while holding the shared [analysisLock]. **All** Analysis API access must go through
* this helper (or [analyzeMaybeDangling], which already does); never call `analyze` / `analyzeCopy`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Convention isn't enforced. "Never call analyze/analyzeCopy directly" is documentation-only; nothing stops a new feature (hover, go-to-def, a new code action) from calling analyze() or reading a Ka* result without the lock — it compiles, passes single-threaded tests, and crashes intermittently under concurrent indexing. Findings #1/#2 in the review summary are existing instances. Making analyze/analyzeCopy reachable only through the locked helper (and returning extracted data, not lifetime owners) would make the guarantee structural rather than by-convention.

* directly, or the serialization guarantee is lost.
*/
internal inline fun <R> withAnalysisLock(action: () -> R): R = analysisLock.withLock(action)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟧 Responsiveness / pool starvation. This is a blocking ReentrantLock held across the entire analyze block and acquired from coroutines on Dispatchers.Default. Indexing (full-file declaration traversal) and diagnostics (collectDiagnostics(EXTENDED_AND_COMMON_CHECKERS) — slow, uncancellable) hold it for the whole resolution, so a user completion request parks behind background batch work; abortIfCancelled() can't interrupt a pending lock.lock(). Because the wait blocks the underlying Default worker thread (bounded ~Ncpu, 2–4 on low-end devices), several contending analyses pin most of the pool while blocked. Net effect: previously-parallel indexing/diagnostics/completion become fully serial and interactive latency regresses. Consider a kotlinx.coroutines.sync.Mutex (suspends instead of blocking the thread) and/or holding the lock only around session entry rather than the full resolution.


return analyze(useSiteElement, action)
}
internal inline fun <R> analyzeMaybeDangling(useSiteElement: KtElement, crossinline action: KaSession.() -> R): R =
withAnalysisLock {
if (useSiteElement is KtFile && useSiteElement.isDangling && useSiteElement.copyOrigin != null) {
analyzeCopy(useSiteElement, KaDanglingFileResolutionMode.PREFER_SELF, action)
} else {
analyze(useSiteElement, action)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.itsaky.androidide.lsp.kotlin.completion
import com.itsaky.androidide.lookup.Lookup
import com.itsaky.androidide.lsp.api.describeSnippet
import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment
import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling
import com.itsaky.androidide.lsp.kotlin.compiler.read
import com.itsaky.androidide.lsp.kotlin.utils.AnalysisContext
import com.itsaky.androidide.lsp.kotlin.utils.ContextKeywords
Expand Down Expand Up @@ -30,8 +31,6 @@ import org.jetbrains.kotlin.analysis.api.KaContextParameterApi
import org.jetbrains.kotlin.analysis.api.KaExperimentalApi
import org.jetbrains.kotlin.analysis.api.KaIdeApi
import org.jetbrains.kotlin.analysis.api.KaSession
import org.jetbrains.kotlin.analysis.api.analyzeCopy
import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode
import org.jetbrains.kotlin.analysis.api.renderer.types.KaTypeRenderer
import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource
import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol
Expand Down Expand Up @@ -149,10 +148,7 @@ internal fun doComplete(params: CompletionParams): CompletionResult {
env.project.read {
abortIfCancelled()

analyzeCopy(
useSiteElement = completionKtFile,
resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF,
) {
analyzeMaybeDangling(completionKtFile) {
val ctx =
resolveAnalysisContext(
env = env,
Expand All @@ -168,7 +164,7 @@ internal fun doComplete(params: CompletionParams): CompletionResult {
completionOffset,
params.file
)
return@analyzeCopy CompletionResult.EMPTY
return@analyzeMaybeDangling CompletionResult.EMPTY
}

abortIfCancelled()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.jetbrains.kotlin.analysis.api.KaExperimentalApi
import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter
import org.jetbrains.kotlin.analysis.api.diagnostics.KaDiagnosticWithPsi
import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity
import org.jetbrains.kotlin.analysis.api.fir.diagnostics.KaFirDiagnostic
import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange
import org.jetbrains.kotlin.com.intellij.psi.PsiErrorElement
import org.jetbrains.kotlin.com.intellij.psi.PsiFile
Expand All @@ -23,7 +24,14 @@ import java.nio.file.Path
private val logger = LoggerFactory.getLogger("KotlinDiagnosticProvider")

internal data class KotlinDiagnosticExtra(
val diagnostic: KaDiagnosticWithPsi<*>,
/**
* The unresolved-reference name extracted from an [KaFirDiagnostic.UnresolvedReference]
* diagnostic, or `null` for any other diagnostic. This is plain data extracted *inside* the
* `analyze` block on purpose: storing the [KaDiagnosticWithPsi] (a `KaLifetimeOwner`) here and
* reading its members later from a code action would access it outside an `analyze` context and
* crash with `KaInaccessibleLifetimeOwnerAccessException`.
*/
val unresolvedReference: String?,
val compilationEnv: CompilationEnvironment,
)

Expand Down Expand Up @@ -79,8 +87,12 @@ private fun doAnalyze(file: Path, cancelChecker: ICancelChecker): DiagnosticResu
ktFile.collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS)
.forEach { diagnostic ->
cancelChecker.abortIfCancelled()
// Extract plain data while still inside the analyze context; never let
// the KaLifetimeOwner diagnostic escape (see KotlinDiagnosticExtra).
val unresolvedReference =
(diagnostic as? KaFirDiagnostic.UnresolvedReference)?.reference
add(diagnostic.toDiagnosticItem().apply {
extra = KotlinDiagnosticExtra(diagnostic, env)
extra = KotlinDiagnosticExtra(unresolvedReference, env)
})
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.itsaky.androidide.lsp.kotlin.compiler.modules

import com.google.common.truth.Truth.assertThat
import com.itsaky.androidide.lsp.kotlin.compiler.read
import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Test
import java.util.Collections
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.max

/**
* Regression tests for the `KaInaccessibleLifetimeOwnerAccessException: ... Called outside an
* `analyze` context.` reported in Sentry (APPDEVFORALL-VR / 7454434587).
*
* Root cause: indexing, diagnostics and completion all drove the stock Kotlin Analysis API
* concurrently from `Dispatchers.Default` threads (the modified-file indexer is debounced into
* independent coroutines), frequently against the same file. The Analysis API tracks its `analyze`
* lifetime context in a per-thread stack and is not safe to run concurrently without platform
* read-action coordination (which this LSP replaces with a shared read lock that does not serialize
* analysis). Overlapping `analyze` calls corrupted the lifetime/session lifecycle.
*
* Fix: [analyzeMaybeDangling] / [withAnalysisLock] hold a process-wide reentrant lock so analyses
* are mutually exclusive.
*
* Both tests fail before the fix (either by throwing the exception or by observing overlapping
* analyses) and pass after it.
*/
class AnalysisSerializationTest : KtLspTest() {

@Test
fun `concurrent analyzeMaybeDangling never throws lifetime exception`(): Unit = runBlocking {
val files = (0 until 8).map { i ->
createSourceFile(
"Concurrent$i.kt",
"""
class Klass$i {
fun member$i(p: Int): Int = p + $i
val prop$i: String = "v$i"
}

fun topLevel$i() = $i
""".trimIndent()
)
}

val errors = Collections.synchronizedList(mutableListOf<Throwable>())

// Many short, overlapping analyses on a high-parallelism dispatcher to reproduce the race.
coroutineScope {
repeat(240) { iter ->
launch(Dispatchers.IO) {
val file = files[iter % files.size]
try {
env.project.read {
analyzeMaybeDangling(file) {
// Touching declaration symbols is what triggered the lifetime check.
file.declarations.forEach { dcl ->
dcl.symbol
}
}
}
} catch (t: Throwable) {
errors.add(t)
}
}
}
}

assertThat(errors).isEmpty()
}

@Test
fun `analyzeMaybeDangling serializes overlapping analyses`(): Unit = runBlocking {
val files = (0 until 8).map { i ->
createSourceFile("Serialized$i.kt", "class S$i { fun f$i() = $i }")
}

val inFlight = AtomicInteger(0)
val maxObserved = AtomicInteger(0)
val errors = Collections.synchronizedList(mutableListOf<Throwable>())

coroutineScope {
repeat(64) { iter ->
launch(Dispatchers.IO) {
val file = files[iter % files.size]
try {
env.project.read {
analyzeMaybeDangling(file) {
val concurrent = inFlight.incrementAndGet()
maxObserved.updateAndGet { max(it, concurrent) }
try {
file.declarations.forEach { it.symbol }
// Widen the window so any real overlap is observed.
Thread.sleep(2)
} finally {
inFlight.decrementAndGet()
}
}
}
} catch (t: Throwable) {
errors.add(t)
}
}
}
}

assertThat(errors).isEmpty()
// The shared analysis lock must prevent two analyses from running at once.
assertThat(maxObserved.get()).isEqualTo(1)
}
}
Loading