[threading] IJPL-179707 Create initial Analyzer, Action, and Service classes for locking requirements analysis

GitOrigin-RevId: 97f4331731dca7808eddbe403789719c911c3523
This commit is contained in:
Moncef Slimani
2025-07-29 15:28:45 +01:00
committed by intellij-monorepo-bot
parent 0a394b8fa4
commit 08fe7ab2bc
16 changed files with 442 additions and 0 deletions

View File

@@ -103,5 +103,9 @@
<orderEntry type="module" module-name="intellij.platform.eel.provider" />
<orderEntry type="module" module-name="intellij.libraries.xerces" />
<orderEntry type="module" module-name="intellij.platform.usageView.impl" />
<orderEntry type="module" module-name="intellij.platform.jewel.foundation" />
<orderEntry type="library" name="studio-platform" level="project" />
<orderEntry type="module" module-name="intellij.platform.jewel.ideLafBridge" />
<orderEntry type="module" module-name="intellij.platform.jewel.ui" />
</component>
</module>

View File

@@ -7,6 +7,14 @@
<module name="intellij.properties.backend"/>
<module name="intellij.properties.backend.psi"/>
<module name="intellij.libraries.xerces"/>
<module name="intellij.platform.jewel.foundation"/>
<module name="intellij.platform.jewel.ui"/>
<module name="intellij.platform.jewel.ideLafBridge"/>
<module name="intellij.libraries.compose.foundation.desktop"/>
<module name="intellij.libraries.skiko"/>
<module name="intellij.platform.jewel.markdown.core"/>
<module name="intellij.platform.jewel.markdown.ideLafBridgeStyling"/>
</dependencies>
<resource-bundle>messages.DevKitBundle</resource-bundle>
@@ -709,6 +717,7 @@
<notificationGroup id="testdata" displayType="BALLOON" bundle="messages.DevKitBundle" key="notification.group.testdata"/>
<!-- Threading Model Helper -->
<buildProcess.parametersProvider implementation="org.jetbrains.idea.devkit.threadingModelHelper.TMHBuildProcessParametersProvider"/>
<registryKey key="tmh.generate.assertions.for.annotations" defaultValue="true"
description="Generate assertions for @RequiresEdt and similar annotations.
@@ -730,6 +739,11 @@
<generatedSourcesFilter implementation="org.jetbrains.idea.devkit.contentReport.ContentReportGeneratedSourcesFilter"/>
<toolWindow
id="LockReqsToolWindow"
factoryClass="org.jetbrains.idea.devkit.threadingModelHelper.LockReqsToolWindowFactory"
anchor="bottom"/>
<!-- INTERNAL -->
<consoleFilterProvider implementation="org.jetbrains.idea.devkit.run.ModulePathFilterProvider"/>
@@ -919,6 +933,7 @@
<action internal="true" class="org.jetbrains.idea.devkit.actions.ShowSerializedXmlAction" id="ShowSerializedXml"/>
<action internal="true" class="org.jetbrains.idea.devkit.actions.ShowHelpPageByIdAction" id="ShowHelpPageById"/>
<action id="AnalyzeUnloadablePlugins" internal="true" class="org.jetbrains.idea.devkit.internal.AnalyzeUnloadablePluginsAction"/>
<action id="LockReqsAnalysis" internal="true" class="org.jetbrains.idea.devkit.threadingModelHelper.LockReqsAction"/>
<add-to-group group-id="Internal" anchor="last"/>
</group>

View File

@@ -0,0 +1,32 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.idea.devkit.threadingModelHelper
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.psi.PsiJavaFile
import com.intellij.psi.PsiMethod
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.openapi.components.service
class LockReqsAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
val psiFile = e.getData(CommonDataKeys.PSI_FILE) ?: return
val element = psiFile.findElementAt(editor.caretModel.offset)
val method = PsiTreeUtil.getParentOfType(element, PsiMethod::class.java) ?: return
project.service<LockReqsService>().updateResults(method)
}
override fun update(e: AnActionEvent) {
val psiFile = e.getData(CommonDataKeys.PSI_FILE)
e.presentation.isEnabledAndVisible = psiFile is PsiJavaFile
}
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
}

View File

@@ -0,0 +1,115 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.idea.devkit.threadingModelHelper
import com.intellij.psi.PsiMethod
import com.intellij.psi.PsiMethodCallExpression
import com.intellij.psi.JavaRecursiveElementVisitor
import com.intellij.psi.PsiMethodReferenceExpression
class LockReqsAnalyzer {
companion object {
private const val ASSERT_READ_ACCESS_METHOD = "assertReadAccess"
private const val THREADING_ASSERTIONS_CLASS = "com.intellij.util.concurrency.ThreadingAssertions"
private const val REQUIRES_READ_LOCK_ANNOTATION = "com.intellij.util.concurrency.annotations.RequiresReadLock"
private const val MAX_PATH_DEPTH = 1000
enum class LockCheckType {
ANNOTATION,
ASSERTION
}
data class LockRequirement(
val type: LockCheckType,
val method: PsiMethod,
)
data class ExecutionPath(
val methodChain: List<PsiMethod>,
val lockRequirement: LockRequirement,
) {
val pathString: String
get() = buildString {
append(methodChain.joinToString(" -> ") {
"${it.containingClass?.name}.${it.name}" +
when (lockRequirement.type) {
LockCheckType.ANNOTATION -> "@RequiresReadLock"
LockCheckType.ASSERTION -> "ThreadingAssertions.assertReadAccess()"
}
})
}
}
}
private val processed = mutableSetOf<PsiMethod>()
fun analyzeMethod(method: PsiMethod): List<ExecutionPath> {
processed.clear()
val paths = mutableListOf<ExecutionPath>()
val currentPath = mutableListOf<PsiMethod>()
processMethodDFS(method, currentPath, paths)
return paths
}
private fun processMethodDFS(
method: PsiMethod,
currentPath: MutableList<PsiMethod>,
paths: MutableList<ExecutionPath>,
) {
if (method in processed || currentPath.size > MAX_PATH_DEPTH) return
processed.add(method)
currentPath.add(method)
findLockChecks(method).forEach { check ->
paths.add(ExecutionPath(currentPath.toList(), LockRequirement(check, method)))
}
getMethodCallees(method).forEach { callee -> processMethodDFS(callee, currentPath, paths) }
currentPath.removeAt(currentPath.lastIndex)
}
private fun getMethodCallees(method: PsiMethod): List<PsiMethod> {
val callees = mutableListOf<PsiMethod>()
method.body?.accept(object : JavaRecursiveElementVisitor() {
override fun visitMethodCallExpression(expression: PsiMethodCallExpression) {
super.visitMethodCallExpression(expression)
expression.resolveMethod()?.let { callees.add(it) }
}
override fun visitMethodReferenceExpression(expression: PsiMethodReferenceExpression) {
super.visitMethodReferenceExpression(expression)
(expression.resolve() as? PsiMethod)?.let { callees.add(it) }
}
})
return callees
}
private fun findLockChecks(method: PsiMethod): List<LockCheckType> {
return buildList {
if (hasRequiresReadLockAnnotation(method)) add(LockCheckType.ANNOTATION)
if (hasAssertReadAccessCall(method)) add(LockCheckType.ASSERTION)
}
}
private fun hasRequiresReadLockAnnotation(method: PsiMethod): Boolean {
return method.hasAnnotation(REQUIRES_READ_LOCK_ANNOTATION)
}
private fun isAssertReadAccess(expression: PsiMethodCallExpression): Boolean {
return ASSERT_READ_ACCESS_METHOD == expression.methodExpression.referenceName &&
THREADING_ASSERTIONS_CLASS == expression.resolveMethod()?.containingClass?.qualifiedName
}
private fun hasAssertReadAccessCall(method: PsiMethod): Boolean {
var found = false
method.body?.accept(object : JavaRecursiveElementVisitor() {
override fun visitMethodCallExpression(expression: PsiMethodCallExpression) {
if (!found) {
super.visitMethodCallExpression(expression)
if (isAssertReadAccess(expression)) found = true
}
}
})
return found
}
}

View File

@@ -0,0 +1,21 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.idea.devkit.threadingModelHelper
import com.intellij.openapi.components.Service
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.psi.PsiMethod
@Service(Service.Level.PROJECT)
class LockReqsService(private val project: Project) {
private var currentResults: List<String> = emptyList()
fun updateResults(method: PsiMethod) {
val analyzer = LockReqsAnalyzer()
val paths = analyzer.analyzeMethod(method).map { it.pathString }
currentResults = paths
ToolWindowManager.getInstance(project).getToolWindow("LockReqsToolWindow")?.show()
}
fun getCurrentResults(): List<String> = currentResults
}

View File

@@ -0,0 +1,18 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.idea.devkit.threadingModelHelper
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import org.jetbrains.jewel.ui.component.Text
@Composable
fun LockReqsToolWindow(service: LockReqsService) {
val results = service.getCurrentResults()
Column() {
Text("Found ${results.size} execution paths:")
Column() { items(results) { path -> Text(text = path) }
}
}
}

View File

@@ -0,0 +1,20 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.idea.devkit.threadingModelHelper
import androidx.compose.runtime.Composable
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import org.jetbrains.idea.devkit.DevKitBundle
import org.jetbrains.jewel.bridge.addComposeTab
import org.jetbrains.jewel.ui.component.Text
class LockReqsToolWindowFactory : ToolWindowFactory {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
val service = project.service<LockReqsService>()
toolWindow.addComposeTab(DevKitBundle.message("tab.title.locking.requirements")) {
LockReqsToolWindow(service)
}
}
}

View File

@@ -0,0 +1,18 @@
import testutils.RequiresReadLock;
import testutils.ExpectedPath
@ExpectedPath("AnnotationInChain.testMethod -> AnnotationInChain.intermediateMethod -> " +
"AnnotationInChain.targetMethod -> @RequiresReadLock")
class AnnotationInChain {
void testMethod() {
intermediateMethod();
}
void intermediateMethod() {
targetMethod();
}
@RequiresReadLock
void targetMethod() {
}
}

View File

@@ -0,0 +1,12 @@
import testutils.ThreadingAssertions;
import testutils.ExpectedPath
@ExpectedPath("AnnotationInChain.testMethod -> @RequiresReadLock")
class AnnotationInChain {
void testMethod() {
if (true) {
for (int i = 0; i < 10; i++) {
ThreadingAssertions.assertReadAccess();
}
}
}

View File

@@ -0,0 +1,12 @@
import testutils.RequiresReadLock;
import testutils.ThreadingAssertions;
import test.ExpectedPath
@ExpectedPath("BothAnnotationAndAssertion.testMethod -> @RequiresReadLock")
@ExpectedPath("BothAnnotationAndAssertion.testMethod -> ThreadingAssertions.assertReadAccess()")
class BothAnnotationAndAssertion {
@RequiresReadLock
void testMethod() {
ThreadingAssertions.assertReadAccess();
}
}

View File

@@ -0,0 +1,18 @@
import testutils.RequiresReadLock;
import testutils.ExpectedPath
@ExpectedPath("CyclicRecursiveCalls.testMethod -> CyclicRecursiveCalls.methodB -> @RequiresReadLock")
class CyclicRecursiveCalls {
void testMethod() {
methodB();
}
@RequiresReadLock
void methodB() {
methodC();
}
void methodC() {
testMethod();
}
}

View File

@@ -0,0 +1,16 @@
import testutils.ThreadingAssertions;
import testutils.ExpectedPath
import java.util.Arrays;
import java.util.List;
@ExpectedPath("LambdaWithMethodReference.testMethod -> LambdaWithMethodReference.processItem -> ThreadingAssertions.assertReadAccess()")
class LambdaWithMethodReference {
void testMethod() {
List<String> items = Arrays.asList("a", "b", "c");
items.forEach(this::processItem);
}
void processItem(String item) {
ThreadingAssertions.assertReadAccess();
}
}

View File

@@ -0,0 +1,27 @@
import testutils.RequiresReadLock;
import testutils.ThreadingAssertions;
import testutils.ExpectedPath;
@ExpectedPath("MethodsInDifferentClasses.testMethod -> Helper.helperMethod -> Service.serviceMethod -> @RequiresReadLock")
@ExpectedPath("MethodsInDifferentClasses.testMethod -> Helper.helperMethod -> Service.serviceMethod -> ThreadingAssertions.assertReadAccess()")
class MethodsInDifferentClasses {
void testMethod() {
Helper helper = new Helper();
helper.helperMethod();
}
class Helper {
void helperMethod() {
Service service = new Service();
service.serviceMethod();
}
}
class Service {
@RequiresReadLock
void serviceMethod() {
ThreadingAssertions.assertReadAccess();
}
}
}

View File

@@ -0,0 +1,17 @@
import testutils.ThreadingAssertions;
import testutils.ExpectedPath
@ExpectedPath("MultipleAssertionsInMethod.testMethod -> ThreadingAssertions.assertReadAccess()")
class MultipleAssertionsInMethod {
void testMethod() {
ThreadingAssertions.assertReadAccess();
doSomething();
ThreadingAssertions.assertReadAccess();
if (condition()) {
ThreadingAssertions.assertReadAccess();
}
}
void doSomething() {}
boolean condition() { return true; }
}

View File

@@ -0,0 +1,8 @@
public class NoLockRequirements {
void testMethod() {
regularMethod();
}
void regularMethod() {
}
}

View File

@@ -0,0 +1,89 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.idea.devkit.threadingModelHelper
import com.intellij.psi.PsiJavaFile
import com.intellij.testFramework.TestDataPath
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import org.jetbrains.idea.devkit.DevkitJavaTestsUtil
@TestDataPath($$"$CONTENT_ROOT/testData/threadingModelHelper/")
class LockReqsUnitTest : BasePlatformTestCase() {
private lateinit var analyzer: LockReqsAnalyzer
override fun setUp() {
super.setUp()
analyzer = LockReqsAnalyzer()
myFixture.addFileToProject("testutils/RequiresReadLock.java", """
package testutils;
public @interface RequiresReadLock {}
""".trimIndent())
myFixture.addFileToProject("testutils/ThreadingAssertions.java", """
package testutils;
public class ThreadingAssertions {
public static void assertReadAccess() {}
}
""".trimIndent())
myFixture.addFileToProject("testutils/ExpectedPath.java", """
package testutils;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExpectedPath {
String value();
}
""".trimIndent())
}
override fun getBasePath() = DevkitJavaTestsUtil.TESTDATA_PATH + "threadingModelHelper/"
fun testNoLockRequirements() {
doTest()
}
fun testAnnotationInChain() {
doTest()
}
fun testAssertionInNestedBlock() {
doTest()
}
fun testBothAnnotationAndAssertion() {
doTest()
}
fun testCyclicRecursiveCalls() {
doTest()
}
fun testMethodsInDifferentClasses() {
doTest()
}
fun testMultipleAssertionsInMethod() {
doTest()
}
fun testLambdaWithMethodReference() {
doTest()
}
private fun doTest() {
val fileName = "${getTestName(false)}.java"
val sourceMethodName = "testMethod"
val psiJavaFile = myFixture.configureByFile(fileName) as PsiJavaFile
val testClass = psiJavaFile.classes.first()
val expectedPaths = testClass.annotations
.filter { it.qualifiedName == "test.ExpectedPath" }
.mapNotNull { it.findAttributeValue("value")?.text?.removeSurrounding("\"") }
.sorted()
val sourceMethod = testClass.findMethodsByName(sourceMethodName, false).first()
val actualPaths = analyzer.analyzeMethod(sourceMethod).map { it.pathString }.sorted()
assertEquals(expectedPaths, actualPaths)
}
}