From a1ca78270ba672eb643fd449add8062119f47680 Mon Sep 17 00:00:00 2001 From: Roman Borodavkin Date: Fri, 14 Aug 2026 15:32:59 +0300 Subject: [PATCH 1/5] refactor(dictionary): load manual dictionaries outside the EDT - Prevent manual SDEF generation and parsing from blocking the UI while preserving file selection and application-name prompts - Serialize cancellable project tasks, publish successful PSI on the EDT under service synchronization, and discard pending work after project disposal - Cover background execution, cache publication, cancellation, FIFO ordering, and disposal Impact: Manual dictionary loading stays responsive without changing cache precedence or partial-success behavior --- .../AppleScriptProjectDictionaryService.kt | 19 +- .../lang/ide/actions/LoadDictionaryAction.kt | 140 +++++++++++-- .../test/concurrency/EdtBridgeGuardTest.kt | 186 ++++++++++++++++++ 3 files changed, 324 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/project/AppleScriptProjectDictionaryService.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/project/AppleScriptProjectDictionaryService.kt index 0d53bd32..dc7f66db 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/project/AppleScriptProjectDictionaryService.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/dictionary/project/AppleScriptProjectDictionaryService.kt @@ -1,8 +1,10 @@ package com.intellij.plugin.applescript.lang.dictionary.project +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Computable import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import com.intellij.plugin.applescript.lang.dictionary.discovery.ApplicationDiscoveryService @@ -226,14 +228,25 @@ class AppleScriptProjectDictionaryService( fun createDictionaryFromFile( applicationName: String, applicationFile: VirtualFile, - ): ApplicationDictionary? { + ): ApplicationDictionary? = loadDictionaryInfo(applicationName, applicationFile)?.let(::createFromInfo) + + internal fun loadDictionaryInfo( + applicationName: String, + applicationFile: VirtualFile, + ): DictionaryInfo? { val appIoFile = File(applicationFile.path) val info = fileProvider.createAndInitializeInfo(appIoFile, applicationName) if (info == null) { LOG.warn("Failed to get initialized dictionary info for $applicationName from $applicationFile") - return null } - return createFromInfo(info) + return info + } + + @Synchronized + internal fun createFromLoadedInfo(info: DictionaryInfo): ApplicationDictionary? { + val application = ApplicationManager.getApplication() + application.assertIsDispatchThread() + return application.runReadAction(Computable { createFromInfo(info) }) } fun getDictionary(applicationName: String): ApplicationDictionary? = dictionaryMap[applicationName] diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt index 81d0c7b9..2c1c8b66 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt @@ -1,20 +1,34 @@ package com.intellij.plugin.applescript.lang.ide.actions +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer import com.intellij.ide.IdeView import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.fileChooser.FileChooser import com.intellij.openapi.fileChooser.FileChooserDescriptor +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages import com.intellij.openapi.util.text.StringUtil import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile +import com.intellij.plugin.applescript.lang.dictionary.persistence.DictionaryInfo import com.intellij.plugin.applescript.lang.dictionary.project.AppleScriptProjectDictionaryService import com.intellij.plugin.applescript.lang.sdef.ApplicationDictionary import com.intellij.plugin.applescript.lang.sdef.extensionSupported +import java.util.Queue +import java.util.concurrent.CancellationException +import java.util.concurrent.ConcurrentLinkedQueue + +private val LOG: Logger = Logger.getInstance("#${LoadDictionaryAction::class.java.name}") +private const val LOAD_TASK_TITLE = "Loading AppleScript dictionaries" class LoadDictionaryAction : AnAction() { override fun actionPerformed(e: AnActionEvent) { @@ -30,6 +44,34 @@ class LoadDictionaryAction : AnAction() { } } +@Service(Service.Level.PROJECT) +internal class DictionaryLoadQueue( + private val startTask: (Task.Backgroundable) -> Unit = { task -> task.queue() }, +) { + private val pendingTasks = ArrayDeque() + private var isRunning = false + + fun submit(createTask: (() -> Unit) -> Task.Backgroundable) { + ApplicationManager.getApplication().assertIsDispatchThread() + pendingTasks.addLast(createTask(::taskFinished)) + if (!isRunning) startNext() + } + + private fun taskFinished() { + ApplicationManager.getApplication().assertIsDispatchThread() + check(isRunning) { "Cannot finish a dictionary load when none is running" } + startNext() + } + + private fun startNext() { + val nextTask = + generateSequence(pendingTasks::removeFirstOrNull) + .firstOrNull { task -> task.project?.isDisposed != true } + isRunning = nextTask != null + nextTask?.let(startTask) + } +} + private fun Project.projectDirectory(): VirtualFile? { val path = basePath return if (path == null) { @@ -46,7 +88,7 @@ internal fun openLoadDirectoryDialog( ) { val singleApplicationName = appName?.takeUnless { StringUtil.isEmpty(it) } val descriptor = - createDictionaryFileChooserDescriptor( + createChooserDescriptor( chooseMultiple = singleApplicationName == null, ) @@ -55,7 +97,7 @@ internal fun openLoadDirectoryDialog( } } -private fun createDictionaryFileChooserDescriptor(chooseMultiple: Boolean): FileChooserDescriptor = +private fun createChooserDescriptor(chooseMultiple: Boolean): FileChooserDescriptor = FileChooserDescriptor( true, true, @@ -65,34 +107,96 @@ private fun createDictionaryFileChooserDescriptor(chooseMultiple: Boolean): File chooseMultiple, ) -private fun loadSelectedDictionaries( +internal fun loadSelectedDictionaries( project: Project, files: List, singleApplicationName: String?, + loadInfo: ((String, VirtualFile) -> DictionaryInfo?)? = null, + afterPublish: () -> Unit = {}, ) { - val dictionaryService = - project.getService(AppleScriptProjectDictionaryService::class.java) ?: return val supportedFiles = files.filter { extensionSupported(it.extension) } - - if (singleApplicationName != null) { - supportedFiles.firstOrNull()?.let { file -> - dictionaryService.createDictionaryFromFile(singleApplicationName, file) - } - return - } - val dictionaryRequests = - supportedFiles.mapNotNull { file -> - resolveApplicationName(project, file)?.let { applicationName -> - applicationName to file + if (singleApplicationName != null) { + supportedFiles + .firstOrNull() + ?.let { file -> listOf(DictionaryLoadRequest(singleApplicationName, file)) } + .orEmpty() + } else { + supportedFiles.mapNotNull { file -> + resolveApplicationName(project, file)?.let { applicationName -> + DictionaryLoadRequest(applicationName, file) + } } } + if (dictionaryRequests.isEmpty()) return + + val dictionaryService = project.getService(AppleScriptProjectDictionaryService::class.java) ?: return + val loadQueue = project.getService(DictionaryLoadQueue::class.java) ?: return + val loadedInfos: Queue = ConcurrentLinkedQueue() + queueDictionaryLoads( + project = project, + loadQueue = loadQueue, + requests = dictionaryRequests, + loadInfo = { applicationName, file -> + (loadInfo ?: dictionaryService::loadDictionaryInfo)(applicationName, file)?.let(loadedInfos::add) + }, + publishDictionaries = { + if (!project.isDisposed) { + var hasCreatedDictionary = false + generateSequence(loadedInfos::poll).forEach { info -> + if (dictionaryService.createFromLoadedInfo(info) != null) hasCreatedDictionary = true + } + if (hasCreatedDictionary) DaemonCodeAnalyzer.getInstance(project).settingsChanged() + } + afterPublish() + }, + ) +} + +private fun queueDictionaryLoads( + project: Project, + loadQueue: DictionaryLoadQueue, + requests: List, + loadInfo: (String, VirtualFile) -> Unit, + publishDictionaries: () -> Unit = {}, +) { + loadQueue.submit { taskFinished -> + object : Task.Backgroundable(project, LOAD_TASK_TITLE, true) { + override fun run(indicator: ProgressIndicator) { + requests.forEachIndexed { index, request -> + indicator.checkCanceled() + indicator.text = "Loading dictionary for ${request.applicationName}" + try { + loadInfo(request.applicationName, request.file) + } catch (error: ProcessCanceledException) { + throw error + } catch (error: CancellationException) { + throw ProcessCanceledException(error) + } + indicator.fraction = (index + 1).toDouble() / requests.size + } + } + + override fun onThrowable(error: Throwable) { + LOG.error("Failed to load selected AppleScript dictionaries", error) + } - dictionaryRequests.forEach { (applicationName, file) -> - dictionaryService.createDictionaryFromFile(applicationName, file) + override fun onFinished() { + try { + publishDictionaries() + } finally { + taskFinished() + } + } + } } } +private data class DictionaryLoadRequest( + val applicationName: String, + val file: VirtualFile, +) + private fun resolveApplicationName( project: Project, file: VirtualFile, diff --git a/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt b/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt index 246f326a..f79551ac 100644 --- a/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt +++ b/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt @@ -1,16 +1,32 @@ package com.intellij.plugin.applescript.test.concurrency import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.io.FileUtil import com.intellij.plugin.applescript.lang.dictionary.index.SdefIndexService +import com.intellij.plugin.applescript.lang.dictionary.persistence.DictionaryInfo +import com.intellij.plugin.applescript.lang.dictionary.project.AppleScriptProjectDictionaryService import com.intellij.plugin.applescript.lang.dictionary.readiness.DictionaryReadinessTracker +import com.intellij.plugin.applescript.lang.ide.actions.DictionaryLoadQueue +import com.intellij.plugin.applescript.lang.ide.actions.loadSelectedDictionaries import com.intellij.plugin.applescript.lang.ide.sdef.AppleScriptSystemDictionaryRegistryService +import com.intellij.testFramework.LightVirtualFile +import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService import kotlinx.coroutines.cancel import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import org.junit.Assume +import java.lang.reflect.Proxy +import java.util.concurrent.CancellationException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.system.measureTimeMillis /** @@ -94,6 +110,176 @@ class EdtBridgeGuardTest : BasePlatformTestCase() { ) } + fun testManualLoadThreading() { + val application = ApplicationManager.getApplication() + assertTrue("fixture must invoke the action boundary on EDT", application.isDispatchThread) + val dictionaryFile = FileUtil.createTempFile("manual-load", ".sdef", true) + dictionaryFile.writeText("") + val loadThread = CompletableFuture() + val loadedName = CompletableFuture() + val publishThread = CompletableFuture() + val projectDictionaries = project.getService(AppleScriptProjectDictionaryService::class.java) + + try { + loadSelectedDictionaries( + project = project, + files = listOf(LightVirtualFile("Calendar.sdef", "")), + singleApplicationName = "Calendar", + loadInfo = { applicationName, _ -> + loadThread.complete(application.isDispatchThread) + loadedName.complete(applicationName) + DictionaryInfo(applicationName, dictionaryFile, null).also { it.setInitialized(true) } + }, + afterPublish = { + publishThread.complete(application.isDispatchThread) + }, + ) + + waitForCompletion("dictionary load did not finish", loadedName) + assertEquals("Calendar", loadedName.getNow(null)) + assertFalse("manual dictionary loading must run outside EDT", loadThread.getNow(true)) + waitForCompletion("dictionary publish callback did not finish", publishThread) + assertTrue("loaded dictionary PSI must be published on EDT", publishThread.getNow(false)) + assertNotNull( + "manual load must publish into the project cache", + projectDictionaries.getDictionary("Calendar"), + ) + } finally { + projectDictionaries.clearCachedDictionariesForTests() + dictionaryFile.delete() + } + } + + fun testManualLoadCancellation() { + val finished = CompletableFuture() + + loadSelectedDictionaries( + project = project, + files = listOf(LightVirtualFile("Calendar.sdef", "")), + singleApplicationName = "Calendar", + loadInfo = { _, _ -> throw CancellationException("test cancellation") }, + afterPublish = { + finished.complete(true) + }, + ) + + waitForCompletion("cancelled dictionary load did not finish", finished) + assertTrue(finished.getNow(false)) + } + + fun testManualRequestOrder() { + val firstDictionaryFile = FileUtil.createTempFile("manual-load-first", ".sdef", true) + val secondDictionaryFile = FileUtil.createTempFile("manual-load-second", ".sdef", true) + firstDictionaryFile.writeText("") + secondDictionaryFile.writeText("") + val secondLoadStarted = CountDownLatch(1) + val allowFirstReturn = CountDownLatch(1) + val publishedLoads = CopyOnWriteArrayList() + val completedLoads = CompletableFuture() + val projectDictionaries = project.getService(AppleScriptProjectDictionaryService::class.java) + + try { + loadSelectedDictionaries( + project = project, + files = listOf(LightVirtualFile("Calendar.sdef", "")), + singleApplicationName = "Calendar", + loadInfo = { applicationName, _ -> + if (secondLoadStarted.await(250, TimeUnit.MILLISECONDS)) { + assertTrue(allowFirstReturn.await(5, TimeUnit.SECONDS)) + } + DictionaryInfo(applicationName, firstDictionaryFile, null).also { it.setInitialized(true) } + }, + afterPublish = { + publishedLoads.add("first") + if (publishedLoads.size == 2) completedLoads.complete(true) + }, + ) + loadSelectedDictionaries( + project = project, + files = listOf(LightVirtualFile("Calendar.sdef", "")), + singleApplicationName = "Calendar", + loadInfo = { applicationName, _ -> + secondLoadStarted.countDown() + DictionaryInfo(applicationName, secondDictionaryFile, null).also { it.setInitialized(true) } + }, + afterPublish = { + publishedLoads.add("second") + allowFirstReturn.countDown() + if (publishedLoads.size == 2) completedLoads.complete(true) + }, + ) + + waitForCompletion("manual dictionary loads did not finish", completedLoads) + assertEquals(listOf("first", "second"), publishedLoads) + } finally { + allowFirstReturn.countDown() + projectDictionaries.clearCachedDictionariesForTests() + firstDictionaryFile.delete() + secondDictionaryFile.delete() + } + } + + fun testManualLoadQueueOrder() { + val startedTasks = mutableListOf() + val taskCompletions = mutableMapOf Unit>() + val loadQueue = DictionaryLoadQueue { task -> startedTasks.add(task.title) } + + fun submitTask(title: String) { + loadQueue.submit { taskFinished -> + taskCompletions[title] = taskFinished + object : Task.Backgroundable(project, title, true) { + override fun run(indicator: ProgressIndicator) = Unit + } + } + } + + submitTask("first") + submitTask("second") + assertEquals(listOf("first"), startedTasks) + + taskCompletions.getValue("first").invoke() + assertEquals(listOf("first", "second"), startedTasks) + taskCompletions.getValue("second").invoke() + } + + fun testQueueDropsDisposedLoads() { + var isDisposed = false + val disposedProject = + Proxy.newProxyInstance( + Project::class.java.classLoader, + arrayOf(Project::class.java), + ) { _, method, _ -> + check(method.name == "isDisposed") { "Unexpected project call: ${method.name}" } + isDisposed + } as Project + val startedTasks = mutableListOf() + val taskCompletions = mutableMapOf Unit>() + val loadQueue = DictionaryLoadQueue { task -> startedTasks.add(task.title) } + + fun submitTask(title: String) { + loadQueue.submit { taskFinished -> + taskCompletions[title] = taskFinished + object : Task.Backgroundable(disposedProject, title, true) { + override fun run(indicator: ProgressIndicator) = Unit + } + } + } + + submitTask("first") + submitTask("second") + isDisposed = true + taskCompletions.getValue("first").invoke() + + assertEquals(listOf("first"), startedTasks) + } + + private fun waitForCompletion( + message: String, + completion: CompletableFuture<*>, + ) { + PlatformTestUtil.waitWithEventsDispatching(message, completion::isDone, 5) + } + companion object { private const val MAX_EDT_LOOKUP_MILLIS = 1_000L } From b3a9f4da199c9eb275a9f619cea94082659f3d84 Mon Sep 17 00:00:00 2001 From: Roman Borodavkin Date: Fri, 14 Aug 2026 16:50:20 +0300 Subject: [PATCH 2/5] test(dictionary): lock manual load lifecycle behavior - Prove every selected file runs while successful dictionaries still publish - Verify project disposal skips cache publication and daemon restart - Group injectable load effects behind one narrow internal seam Impact: manual loads keep partial successes without leaking post-disposal updates --- .../lang/ide/actions/LoadDictionaryAction.kt | 18 +- .../test/concurrency/EdtBridgeGuardTest.kt | 191 +++++++++++++++--- 2 files changed, 173 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt index 2c1c8b66..561023b0 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt @@ -107,12 +107,17 @@ private fun createChooserDescriptor(chooseMultiple: Boolean): FileChooserDescrip chooseMultiple, ) +internal class DictionaryLoadEffects( + val loadInfo: ((String, VirtualFile) -> DictionaryInfo?)? = null, + val restartDaemon: (() -> Unit)? = null, + val afterPublish: () -> Unit = {}, +) + internal fun loadSelectedDictionaries( project: Project, files: List, singleApplicationName: String?, - loadInfo: ((String, VirtualFile) -> DictionaryInfo?)? = null, - afterPublish: () -> Unit = {}, + effects: DictionaryLoadEffects = DictionaryLoadEffects(), ) { val supportedFiles = files.filter { extensionSupported(it.extension) } val dictionaryRequests = @@ -138,7 +143,7 @@ internal fun loadSelectedDictionaries( loadQueue = loadQueue, requests = dictionaryRequests, loadInfo = { applicationName, file -> - (loadInfo ?: dictionaryService::loadDictionaryInfo)(applicationName, file)?.let(loadedInfos::add) + (effects.loadInfo ?: dictionaryService::loadDictionaryInfo)(applicationName, file)?.let(loadedInfos::add) }, publishDictionaries = { if (!project.isDisposed) { @@ -146,9 +151,12 @@ internal fun loadSelectedDictionaries( generateSequence(loadedInfos::poll).forEach { info -> if (dictionaryService.createFromLoadedInfo(info) != null) hasCreatedDictionary = true } - if (hasCreatedDictionary) DaemonCodeAnalyzer.getInstance(project).settingsChanged() + if (hasCreatedDictionary) { + effects.restartDaemon?.invoke() + ?: DaemonCodeAnalyzer.getInstance(project).settingsChanged() + } } - afterPublish() + effects.afterPublish() }, ) } diff --git a/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt b/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt index f79551ac..d76e6b99 100644 --- a/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt +++ b/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt @@ -1,6 +1,7 @@ package com.intellij.plugin.applescript.test.concurrency import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.progress.EmptyProgressIndicator import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.Task import com.intellij.openapi.project.Project @@ -10,6 +11,7 @@ import com.intellij.plugin.applescript.lang.dictionary.index.SdefIndexService import com.intellij.plugin.applescript.lang.dictionary.persistence.DictionaryInfo import com.intellij.plugin.applescript.lang.dictionary.project.AppleScriptProjectDictionaryService import com.intellij.plugin.applescript.lang.dictionary.readiness.DictionaryReadinessTracker +import com.intellij.plugin.applescript.lang.ide.actions.DictionaryLoadEffects import com.intellij.plugin.applescript.lang.ide.actions.DictionaryLoadQueue import com.intellij.plugin.applescript.lang.ide.actions.loadSelectedDictionaries import com.intellij.plugin.applescript.lang.ide.sdef.AppleScriptSystemDictionaryRegistryService @@ -125,14 +127,17 @@ class EdtBridgeGuardTest : BasePlatformTestCase() { project = project, files = listOf(LightVirtualFile("Calendar.sdef", "")), singleApplicationName = "Calendar", - loadInfo = { applicationName, _ -> - loadThread.complete(application.isDispatchThread) - loadedName.complete(applicationName) - DictionaryInfo(applicationName, dictionaryFile, null).also { it.setInitialized(true) } - }, - afterPublish = { - publishThread.complete(application.isDispatchThread) - }, + effects = + DictionaryLoadEffects( + loadInfo = { applicationName, _ -> + loadThread.complete(application.isDispatchThread) + loadedName.complete(applicationName) + DictionaryInfo(applicationName, dictionaryFile, null).also { it.setInitialized(true) } + }, + afterPublish = { + publishThread.complete(application.isDispatchThread) + }, + ), ) waitForCompletion("dictionary load did not finish", loadedName) @@ -157,16 +162,134 @@ class EdtBridgeGuardTest : BasePlatformTestCase() { project = project, files = listOf(LightVirtualFile("Calendar.sdef", "")), singleApplicationName = "Calendar", - loadInfo = { _, _ -> throw CancellationException("test cancellation") }, - afterPublish = { - finished.complete(true) - }, + effects = + DictionaryLoadEffects( + loadInfo = { _, _ -> throw CancellationException("test cancellation") }, + afterPublish = { + finished.complete(true) + }, + ), ) waitForCompletion("cancelled dictionary load did not finish", finished) assertTrue(finished.getNow(false)) } + fun testPartialLoadPublishes() { + val dictionaryFile = FileUtil.createTempFile("partial-manual-load", ".sdef", true) + dictionaryFile.writeText("") + val loadedApplication = "SyntheticPartialSuccess_${System.nanoTime()}" + val failedApplication = "SyntheticPartialFailure_${System.nanoTime()}" + val attemptedApplications = CopyOnWriteArrayList() + val finished = CompletableFuture() + val projectDictionaries = project.getService(AppleScriptProjectDictionaryService::class.java) + + try { + loadSelectedDictionaries( + project = project, + files = + listOf( + LightVirtualFile("$loadedApplication.app", ""), + LightVirtualFile("$failedApplication.app", ""), + ), + singleApplicationName = null, + effects = + DictionaryLoadEffects( + loadInfo = { applicationName, _ -> + attemptedApplications.add(applicationName) + if (applicationName == loadedApplication) { + DictionaryInfo(applicationName, dictionaryFile, null).also { it.setInitialized(true) } + } else { + null + } + }, + afterPublish = { finished.complete(true) }, + ), + ) + + waitForCompletion("partial dictionary load did not finish", finished) + assertEquals(listOf(loadedApplication, failedApplication), attemptedApplications) + assertNotNull( + "a successful dictionary must remain available when another selected file fails", + projectDictionaries.getDictionary(loadedApplication), + ) + assertNull(projectDictionaries.getDictionary(failedApplication)) + } finally { + projectDictionaries.clearCachedDictionariesForTests() + dictionaryFile.delete() + } + } + + fun testDisposedLoadSkipsPublish() { + val dictionaryFile = FileUtil.createTempFile("disposed-manual-load", ".sdef", true) + dictionaryFile.writeText("") + val loadStarted = CountDownLatch(1) + val allowLoadToFinish = CountDownLatch(1) + val publishFinished = CompletableFuture() + val projectDictionaries = project.getService(AppleScriptProjectDictionaryService::class.java) + var daemonRestarts = 0 + var isDisposed = false + var queuedTask: Task.Backgroundable? = null + val loadQueue = DictionaryLoadQueue { task -> queuedTask = task } + val projectProxy = + Proxy.newProxyInstance( + Project::class.java.classLoader, + arrayOf(Project::class.java), + ) { _, method, arguments -> + when (method.name) { + "isDisposed" -> isDisposed + "getService" -> + when (arguments?.single()) { + AppleScriptProjectDictionaryService::class.java -> projectDictionaries + DictionaryLoadQueue::class.java -> loadQueue + else -> null + } + + else -> error("Unexpected project call: ${method.name}") + } + } as Project + + try { + loadSelectedDictionaries( + project = projectProxy, + files = listOf(LightVirtualFile("Calendar.app", "")), + singleApplicationName = null, + effects = + DictionaryLoadEffects( + loadInfo = { applicationName, _ -> + loadStarted.countDown() + assertTrue(allowLoadToFinish.await(5, TimeUnit.SECONDS)) + DictionaryInfo(applicationName, dictionaryFile, null).also { it.setInitialized(true) } + }, + restartDaemon = { daemonRestarts++ }, + afterPublish = { publishFinished.complete(true) }, + ), + ) + + val task = requireNotNull(queuedTask) + val worker = + ApplicationManager.getApplication().executeOnPooledThread { + task.run(EmptyProgressIndicator()) + } + assertTrue("dictionary load did not start", loadStarted.await(5, TimeUnit.SECONDS)) + isDisposed = true + allowLoadToFinish.countDown() + worker.get(5, TimeUnit.SECONDS) + task.onFinished() + + assertTrue(publishFinished.getNow(false)) + assertEquals("disposed loads must not restart highlighting", 0, daemonRestarts) + assertNull( + "a dictionary loaded after project disposal must not enter the project cache", + projectDictionaries.getDictionary("Calendar"), + ) + } finally { + allowLoadToFinish.countDown() + projectDictionaries.clearCachedDictionariesForTests() + dictionaryFile.delete() + } + } + fun testManualRequestOrder() { val firstDictionaryFile = FileUtil.createTempFile("manual-load-first", ".sdef", true) val secondDictionaryFile = FileUtil.createTempFile("manual-load-second", ".sdef", true) @@ -183,30 +306,36 @@ class EdtBridgeGuardTest : BasePlatformTestCase() { project = project, files = listOf(LightVirtualFile("Calendar.sdef", "")), singleApplicationName = "Calendar", - loadInfo = { applicationName, _ -> - if (secondLoadStarted.await(250, TimeUnit.MILLISECONDS)) { - assertTrue(allowFirstReturn.await(5, TimeUnit.SECONDS)) - } - DictionaryInfo(applicationName, firstDictionaryFile, null).also { it.setInitialized(true) } - }, - afterPublish = { - publishedLoads.add("first") - if (publishedLoads.size == 2) completedLoads.complete(true) - }, + effects = + DictionaryLoadEffects( + loadInfo = { applicationName, _ -> + if (secondLoadStarted.await(250, TimeUnit.MILLISECONDS)) { + assertTrue(allowFirstReturn.await(5, TimeUnit.SECONDS)) + } + DictionaryInfo(applicationName, firstDictionaryFile, null).also { it.setInitialized(true) } + }, + afterPublish = { + publishedLoads.add("first") + if (publishedLoads.size == 2) completedLoads.complete(true) + }, + ), ) loadSelectedDictionaries( project = project, files = listOf(LightVirtualFile("Calendar.sdef", "")), singleApplicationName = "Calendar", - loadInfo = { applicationName, _ -> - secondLoadStarted.countDown() - DictionaryInfo(applicationName, secondDictionaryFile, null).also { it.setInitialized(true) } - }, - afterPublish = { - publishedLoads.add("second") - allowFirstReturn.countDown() - if (publishedLoads.size == 2) completedLoads.complete(true) - }, + effects = + DictionaryLoadEffects( + loadInfo = { applicationName, _ -> + secondLoadStarted.countDown() + DictionaryInfo(applicationName, secondDictionaryFile, null).also { it.setInitialized(true) } + }, + afterPublish = { + publishedLoads.add("second") + allowFirstReturn.countDown() + if (publishedLoads.size == 2) completedLoads.complete(true) + }, + ), ) waitForCompletion("manual dictionary loads did not finish", completedLoads) From e7c34c6205bacda6efcd44860941af6d76460386 Mon Sep 17 00:00:00 2001 From: Roman Borodavkin Date: Fri, 14 Aug 2026 17:52:00 +0300 Subject: [PATCH 3/5] fix(dictionary): add request context to load failures - Preserve the application name, file path, and original cause when a background manual load fails - Lock the failing request with a two-file mutation-sensitive regression test Impact: Manual dictionary load failures now identify the exact file that interrupted the batch --- .../lang/ide/actions/LoadDictionaryAction.kt | 5 ++ .../test/concurrency/EdtBridgeGuardTest.kt | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt index 561023b0..a0f1a1f8 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt @@ -180,6 +180,11 @@ private fun queueDictionaryLoads( throw error } catch (error: CancellationException) { throw ProcessCanceledException(error) + } catch (error: RuntimeException) { + throw IllegalStateException( + "Failed to load dictionary '${request.applicationName}' from ${request.file.path}", + error, + ) } indicator.fraction = (index + 1).toDouble() / requests.size } diff --git a/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt b/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt index d76e6b99..1f70dac3 100644 --- a/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt +++ b/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt @@ -175,6 +175,60 @@ class EdtBridgeGuardTest : BasePlatformTestCase() { assertTrue(finished.getNow(false)) } + fun testLoadFailureAddsContext() { + val firstFile = LightVirtualFile("SuccessfulRequest.app", "") + val failingFile = LightVirtualFile("BrokenRequest.app", "") + val failure = IllegalArgumentException("broken registry") + var queuedTask: Task.Backgroundable? = null + val loadQueue = DictionaryLoadQueue { task -> queuedTask = task } + val projectDictionaries = project.getService(AppleScriptProjectDictionaryService::class.java) + val projectProxy = + Proxy.newProxyInstance( + Project::class.java.classLoader, + arrayOf(Project::class.java), + ) { _, method, arguments -> + when (method.name) { + "isDisposed" -> false + "getService" -> + when (arguments?.single()) { + AppleScriptProjectDictionaryService::class.java -> projectDictionaries + DictionaryLoadQueue::class.java -> loadQueue + else -> null + } + + else -> error("Unexpected project call: ${method.name}") + } + } as Project + + loadSelectedDictionaries( + project = projectProxy, + files = listOf(firstFile, failingFile), + singleApplicationName = null, + effects = + DictionaryLoadEffects( + loadInfo = { applicationName, _ -> + if (applicationName == failingFile.nameWithoutExtension) throw failure + null + }, + ), + ) + + val thrown = + runCatching { + requireNotNull(queuedTask).run(EmptyProgressIndicator()) + }.exceptionOrNull() + if (thrown !is IllegalStateException) { + fail("Expected IllegalStateException, got ${thrown?.javaClass?.name}") + return + } + val contextualFailure = thrown + assertEquals( + "Failed to load dictionary 'BrokenRequest' from ${failingFile.path}", + contextualFailure.message, + ) + assertSame(failure, contextualFailure.cause) + } + fun testPartialLoadPublishes() { val dictionaryFile = FileUtil.createTempFile("partial-manual-load", ".sdef", true) dictionaryFile.writeText("") From f3a7ee56f327b71bff4a89c092d31f8bb5fb6bfb Mon Sep 17 00:00:00 2001 From: Roman Borodavkin Date: Fri, 14 Aug 2026 18:26:51 +0300 Subject: [PATCH 4/5] refactor(dictionary): require publication callback --- .../plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt b/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt index a0f1a1f8..5a869d63 100644 --- a/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt +++ b/src/main/kotlin/com/intellij/plugin/applescript/lang/ide/actions/LoadDictionaryAction.kt @@ -166,7 +166,7 @@ private fun queueDictionaryLoads( loadQueue: DictionaryLoadQueue, requests: List, loadInfo: (String, VirtualFile) -> Unit, - publishDictionaries: () -> Unit = {}, + publishDictionaries: () -> Unit, ) { loadQueue.submit { taskFinished -> object : Task.Backgroundable(project, LOAD_TASK_TITLE, true) { From 3aca3f6b382b08b82fb0b6a1471f253f2caf0e84 Mon Sep 17 00:00:00 2001 From: Roman Borodavkin Date: Fri, 14 Aug 2026 18:59:58 +0300 Subject: [PATCH 5/5] test(dictionary): inline failure assertions --- .../applescript/test/concurrency/EdtBridgeGuardTest.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt b/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt index 1f70dac3..ef01f728 100644 --- a/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt +++ b/src/test/kotlin/com/intellij/plugin/applescript/test/concurrency/EdtBridgeGuardTest.kt @@ -221,12 +221,11 @@ class EdtBridgeGuardTest : BasePlatformTestCase() { fail("Expected IllegalStateException, got ${thrown?.javaClass?.name}") return } - val contextualFailure = thrown assertEquals( "Failed to load dictionary 'BrokenRequest' from ${failingFile.path}", - contextualFailure.message, + thrown.message, ) - assertSame(failure, contextualFailure.cause) + assertSame(failure, thrown.cause) } fun testPartialLoadPublishes() {