From 03d73bbc24c6b9cf32f6494a12d8b83f1980ec3f Mon Sep 17 00:00:00 2001 From: Konstantin Nisht Date: Mon, 24 Aug 2026 17:20:18 +0200 Subject: [PATCH] [threading] RUST-20908: Compensate parallelism for `invokeAndWait` IJ-CR-221203 GitOrigin-RevId: 7f4172a0e7ad0180abf1845ef1142067b1eb3082 --- .../intellij/openapi/progress/util/util.kt | 3 +- .../application/impl/ApplicationImpl.java | 5 +- .../com/intellij/concurrency/suites.kt | 2 + .../impl/InvokeAndWaitStarvationTest.kt | 91 +++++++++++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 platform/platform-tests/testSrc/com/intellij/openapi/application/impl/InvokeAndWaitStarvationTest.kt diff --git a/platform/ide-core-impl/src/com/intellij/openapi/progress/util/util.kt b/platform/ide-core-impl/src/com/intellij/openapi/progress/util/util.kt index 010ae9718972..3be411ecffa7 100644 --- a/platform/ide-core-impl/src/com/intellij/openapi/progress/util/util.kt +++ b/platform/ide-core-impl/src/com/intellij/openapi/progress/util/util.kt @@ -18,7 +18,8 @@ import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.milliseconds -internal fun waitWithParallelismCompensation(runnable: Runnable) { +@ApiStatus.Internal +fun waitWithParallelismCompensation(runnable: Runnable) { IntelliJCoroutinesFacade.runAndCompensateParallelism(500.milliseconds, runnable::run) } diff --git a/platform/platform-impl/src/com/intellij/openapi/application/impl/ApplicationImpl.java b/platform/platform-impl/src/com/intellij/openapi/application/impl/ApplicationImpl.java index 2885d7289252..95263cd653e5 100644 --- a/platform/platform-impl/src/com/intellij/openapi/application/impl/ApplicationImpl.java +++ b/platform/platform-impl/src/com/intellij/openapi/application/impl/ApplicationImpl.java @@ -55,6 +55,7 @@ import com.intellij.openapi.progress.impl.ProgressRunner; import com.intellij.openapi.progress.util.PotemkinProgress; import com.intellij.openapi.progress.util.ProgressWindow; import com.intellij.openapi.progress.util.SuvorovProgress; +import com.intellij.openapi.progress.util.UtilKt; import com.intellij.openapi.project.Project; import com.intellij.openapi.project.ProjectManager; import com.intellij.openapi.project.ex.ProjectManagerEx; @@ -627,7 +628,9 @@ public final class ApplicationImpl extends ClientAwareComponentManager implement // Outer layer context capture & reset final var finalRunnable = AppImplKt.rethrowExceptions(AppScheduledExecutorService::captureContextCancellationForRunnableThatDoesNotOutliveContextScope, locked); - LaterInvocator.invokeAndWait(state, wrapWithLocks, finalRunnable); + UtilKt.waitWithParallelismCompensation(() -> { + LaterInvocator.invokeAndWait(state, wrapWithLocks, finalRunnable); + }); } private @NotNull Runnable wrapWithRunIntendedWriteActionAndModality(@NotNull Runnable runnable, diff --git a/platform/platform-tests/testSrc/com/intellij/concurrency/suites.kt b/platform/platform-tests/testSrc/com/intellij/concurrency/suites.kt index f9987aab26c5..8837d583a425 100644 --- a/platform/platform-tests/testSrc/com/intellij/concurrency/suites.kt +++ b/platform/platform-tests/testSrc/com/intellij/concurrency/suites.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.application.impl.CancellableReadActionWithIndicatorT import com.intellij.openapi.application.impl.CancellableReadActionWithJobTest import com.intellij.openapi.application.impl.EdtCoroutineDispatcherTest import com.intellij.openapi.application.impl.ImplicitReadTest +import com.intellij.openapi.application.impl.InvokeAndWaitStarvationTest import com.intellij.openapi.application.impl.LaterInvocatorTest import com.intellij.openapi.application.impl.LockDowngradingTest import com.intellij.openapi.application.impl.ModalCoroutineTest @@ -51,6 +52,7 @@ import org.junit.platform.suite.api.Suite ProgressRunnerTest::class, EdtCoroutineDispatcherTest::class, ImplicitReadTest::class, + InvokeAndWaitStarvationTest::class, LaterInvocatorTest::class, ModalCoroutineTest::class, ReadWritePropagationTest::class, diff --git a/platform/platform-tests/testSrc/com/intellij/openapi/application/impl/InvokeAndWaitStarvationTest.kt b/platform/platform-tests/testSrc/com/intellij/openapi/application/impl/InvokeAndWaitStarvationTest.kt new file mode 100644 index 000000000000..09f59793d67d --- /dev/null +++ b/platform/platform-tests/testSrc/com/intellij/openapi/application/impl/InvokeAndWaitStarvationTest.kt @@ -0,0 +1,91 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.openapi.application.impl + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.common.timeoutRunBlocking +import com.intellij.testFramework.junit5.TestApplication +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.future.asCompletableFuture +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import java.util.concurrent.atomic.AtomicInteger +import javax.swing.SwingUtilities +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@TestApplication +class InvokeAndWaitStarvationTest { + + /** + * [com.intellij.openapi.application.Application.invokeAndWait] parks the calling thread until the EDT picks the runnable up. + * When the EDT is busy, and every [Dispatchers.Default] thread is parked this way, the dispatcher has nothing left to run + * the coroutine that would eventually free the EDT -- neither the queued `invokeAndWait` callers nor the coroutine releasing + * the EDT can make progress, and the IDE deadlocks. + * + * [ApplicationImpl.doInvokeAndWait] avoids this by compensating parallelism while it waits, which spawns extra pool workers. + * + * `BlockingSuspendingReadActionTest.pending read action do not cause thread starvation for default dispatcher` + * covers the same scenario for a read action pending behind a write action. + * + * @see com.intellij.openapi.progress.util.waitWithParallelismCompensation + */ + @Test + @Timeout(30) + fun `invokeAndWait does not cause thread starvation for default dispatcher`(): Unit = timeoutRunBlocking(context = Dispatchers.Default) { + val operationsCount = Runtime.getRuntime().availableProcessors().coerceAtLeast(2) * 2 + val startedInvocations = AtomicInteger() + val completedInvocations = AtomicInteger() + val edtIsOccupied = Job(coroutineContext.job) + val edtMayProceed = Job(coroutineContext.job) + val edtMayProceedFuture = edtMayProceed.asCompletableFuture() + try { + // occupy the EDT, so that every `invokeAndWait` below has to park + SwingUtilities.invokeLater { + edtIsOccupied.complete() + try { + edtMayProceedFuture.join() + } + catch (_: Throwable) { + // the surrounding job was cancelled because the test has failed; the EDT must not stay blocked anyway + } + } + coroutineScope { + launch(Dispatchers.IO) { + // `operationsCount` exceeds the parallelism of `Dispatchers.Default`, so the later callers get a thread + // only after the earlier ones have compensated the parallelism they took away + while (startedInvocations.get() < operationsCount) { + delay(50.milliseconds) + } + // give the last callers a chance to actually park inside `invokeAndWait` + delay(1.seconds) + // the EDT is released from `Dispatchers.Default`: without parallelism compensation there is no thread left + // to run this continuation + withContext(Dispatchers.Default) { + edtMayProceed.complete() + } + } + repeat(operationsCount) { + launch { + edtIsOccupied.join() + startedInvocations.incrementAndGet() + @Suppress("ForbiddenInSuspectContextMethod") // blocking the coroutine thread is exactly what is under test + ApplicationManager.getApplication().invokeAndWait { + completedInvocations.incrementAndGet() + } + } + } + } + } + finally { + edtMayProceed.complete() + } + assertEquals(operationsCount, completedInvocations.get()) + } +}