Skip to content

Commit 90e5471

Browse files
committed
fix: address review findings (lifetime owner escape, write-lock race, completion cleanup)
Apply the actionable items from Hal's review on PR #1428: - Diagnostics: extract the unresolved-reference name inside the analyze block instead of storing the live KaDiagnosticWithPsi (a KaLifetimeOwner) in DiagnosticItem.extra. AddImportAction now reads the pre-extracted string, preventing KaInaccessibleLifetimeOwnerAccessException from the quick-fix path. - CompilationEnvironment.notifyElementModifiedForPath: run handleElementModification inside project.write so the session mutation can't race a concurrent analyze (mirrors onFileContentChanged). - KotlinCompletions: collapse manual withAnalysisLock + analyzeCopy into analyzeMaybeDangling, removing the only in-prod direct analyzeCopy call. - KtFileExts: document that code under withAnalysisLock must not call project.write (non-upgradeable RW lock footgun).
1 parent dc471d3 commit 90e5471

5 files changed

Lines changed: 64 additions & 53 deletions

File tree

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import com.itsaky.androidide.lsp.models.DocumentChange
1818
import com.itsaky.androidide.lsp.models.TextEdit
1919
import com.itsaky.androidide.resources.R
2020
import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol
21-
import org.jetbrains.kotlin.analysis.api.fir.diagnostics.KaFirDiagnostic
2221
import org.slf4j.LoggerFactory
2322

2423
class AddImportAction : BaseKotlinCodeAction() {
@@ -46,14 +45,13 @@ class AddImportAction : BaseKotlinCodeAction() {
4645
return
4746
}
4847

49-
val diagnostic = extra.diagnostic as? KaFirDiagnostic.UnresolvedReference?
50-
if (diagnostic == null) {
48+
val reference = extra.unresolvedReference
49+
if (reference == null) {
5150
markInvisible()
5251
return
5352
}
5453

5554
val env = extra.compilationEnv
56-
val reference = diagnostic.reference
5755
val hasImportableSymbols = env.ktSymbolIndex
5856
.findSymbolBySimpleName(reference, limit = 0)
5957
.any { it.kind.isClassifier }
@@ -65,10 +63,10 @@ class AddImportAction : BaseKotlinCodeAction() {
6563
}
6664

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

71-
diagnostic as KaFirDiagnostic.UnresolvedReference
69+
if (reference == null) return emptyMap()
7270

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

7977
return env.ktSymbolIndex
80-
.findSymbolBySimpleName(diagnostic.reference, limit = 0)
78+
.findSymbolBySimpleName(reference, limit = 0)
8179
.filter { it.kind.isClassifier }
8280
.associateWith { symbol -> insertImport(ktFile, symbol.fqName) }
8381
}

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,22 +208,24 @@ internal class CompilationEnvironment(
208208
@OptIn(KaImplementationDetail::class)
209209
private inline fun notifyElementModifiedForPath(
210210
path: Path,
211-
typeProvider: (KtFile) -> KaElementModificationType,
211+
crossinline typeProvider: (KtFile) -> KaElementModificationType,
212212
) {
213213
val structureProvider = ProjectStructureProvider.getInstance(project)
214214
val ktFile = path.toVirtualFileOrNull()?.let {
215215
psiManager.findFile(it) as? KtFile
216216
}
217217

218-
if (ktFile != null) {
219-
KaSourceModificationService.getInstance(project)
220-
.handleElementModification(ktFile, typeProvider(ktFile))
221-
}
222-
223218
val module = (ktFile?.let { structureProvider.getModule(it, null) }
224219
?: structureProvider.findModuleForSourceId(path.pathString)) as? AbstractKtModule
225220

226221
project.write {
222+
// Must run under the write lock so the session mutation can't race a concurrent
223+
// `analyze` (which only holds the read lock); see onFileContentChanged.
224+
if (ktFile != null) {
225+
KaSourceModificationService.getInstance(project)
226+
.handleElementModification(ktFile, typeProvider(ktFile))
227+
}
228+
227229
if (module != null) {
228230
module.invalidateSearchScope()
229231
project.publishModificationEvent(

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtFileExts.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ var KtFile.backingFilePath by UserDataProperty(KT_LSP_COMPLETION_BACKING_FILE)
3030
*
3131
* Holding this lock around every analysis entry point makes analyses mutually exclusive. It is a
3232
* [ReentrantLock] so an (indirect) nested analysis on the same thread cannot deadlock.
33+
*
34+
* **Footgun:** analysis runs under the *read* (shared) side of the global
35+
* [com.itsaky.androidide.lsp.kotlin.compiler.read] lock, and that `ReentrantReadWriteLock` is
36+
* non-upgradeable. Code running inside [withAnalysisLock] / an `analyze` block must therefore never
37+
* call [com.itsaky.androidide.lsp.kotlin.compiler.write] — upgrading read → write on the same thread
38+
* deadlocks.
3339
*/
3440
private val analysisLock = ReentrantLock()
3541

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt‎

Lines changed: 31 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package com.itsaky.androidide.lsp.kotlin.completion
33
import com.itsaky.androidide.lookup.Lookup
44
import com.itsaky.androidide.lsp.api.describeSnippet
55
import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment
6-
import com.itsaky.androidide.lsp.kotlin.compiler.modules.withAnalysisLock
6+
import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling
77
import com.itsaky.androidide.lsp.kotlin.compiler.read
88
import com.itsaky.androidide.lsp.kotlin.utils.AnalysisContext
99
import com.itsaky.androidide.lsp.kotlin.utils.ContextKeywords
@@ -31,8 +31,6 @@ import org.jetbrains.kotlin.analysis.api.KaContextParameterApi
3131
import org.jetbrains.kotlin.analysis.api.KaExperimentalApi
3232
import org.jetbrains.kotlin.analysis.api.KaIdeApi
3333
import org.jetbrains.kotlin.analysis.api.KaSession
34-
import org.jetbrains.kotlin.analysis.api.analyzeCopy
35-
import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode
3634
import org.jetbrains.kotlin.analysis.api.renderer.types.KaTypeRenderer
3735
import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource
3836
import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol
@@ -150,43 +148,38 @@ internal fun doComplete(params: CompletionParams): CompletionResult {
150148
env.project.read {
151149
abortIfCancelled()
152150

153-
withAnalysisLock {
154-
analyzeCopy(
155-
useSiteElement = completionKtFile,
156-
resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF,
157-
) {
158-
val ctx =
159-
resolveAnalysisContext(
160-
env = env,
161-
file = params.file,
162-
ktFile = completionKtFile,
163-
offset = completionOffset,
164-
partial = partial
165-
)
166-
167-
if (ctx == null) {
168-
logger.error(
169-
"Unable to determine context at offset {} in file {}",
170-
completionOffset,
171-
params.file
172-
)
173-
return@analyzeCopy CompletionResult.EMPTY
174-
}
175-
176-
abortIfCancelled()
177-
context(ctx) {
178-
val items = mutableListOf<CompletionItem>()
179-
val completionContext = determineCompletionContext(ctx.psiElement)
180-
when (completionContext) {
181-
CompletionContext.Scope ->
182-
collectScopeCompletions(to = items)
183-
184-
CompletionContext.Member ->
185-
collectMemberCompletions(to = items)
186-
}
151+
analyzeMaybeDangling(completionKtFile) {
152+
val ctx =
153+
resolveAnalysisContext(
154+
env = env,
155+
file = params.file,
156+
ktFile = completionKtFile,
157+
offset = completionOffset,
158+
partial = partial
159+
)
160+
161+
if (ctx == null) {
162+
logger.error(
163+
"Unable to determine context at offset {} in file {}",
164+
completionOffset,
165+
params.file
166+
)
167+
return@analyzeMaybeDangling CompletionResult.EMPTY
168+
}
187169

188-
CompletionResult(items)
170+
abortIfCancelled()
171+
context(ctx) {
172+
val items = mutableListOf<CompletionItem>()
173+
val completionContext = determineCompletionContext(ctx.psiElement)
174+
when (completionContext) {
175+
CompletionContext.Scope ->
176+
collectScopeCompletions(to = items)
177+
178+
CompletionContext.Member ->
179+
collectMemberCompletions(to = items)
189180
}
181+
182+
CompletionResult(items)
190183
}
191184
}
192185
}

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import org.jetbrains.kotlin.analysis.api.KaExperimentalApi
1313
import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter
1414
import org.jetbrains.kotlin.analysis.api.diagnostics.KaDiagnosticWithPsi
1515
import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity
16+
import org.jetbrains.kotlin.analysis.api.fir.diagnostics.KaFirDiagnostic
1617
import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange
1718
import org.jetbrains.kotlin.com.intellij.psi.PsiErrorElement
1819
import org.jetbrains.kotlin.com.intellij.psi.PsiFile
@@ -23,7 +24,14 @@ import java.nio.file.Path
2324
private val logger = LoggerFactory.getLogger("KotlinDiagnosticProvider")
2425

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

@@ -79,8 +87,12 @@ private fun doAnalyze(file: Path, cancelChecker: ICancelChecker): DiagnosticResu
7987
ktFile.collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS)
8088
.forEach { diagnostic ->
8189
cancelChecker.abortIfCancelled()
90+
// Extract plain data while still inside the analyze context; never let
91+
// the KaLifetimeOwner diagnostic escape (see KotlinDiagnosticExtra).
92+
val unresolvedReference =
93+
(diagnostic as? KaFirDiagnostic.UnresolvedReference)?.reference
8294
add(diagnostic.toDiagnosticItem().apply {
83-
extra = KotlinDiagnosticExtra(diagnostic, env)
95+
extra = KotlinDiagnosticExtra(unresolvedReference, env)
8496
})
8597
}
8698
}

0 commit comments

Comments
 (0)