[platform] IJPL-253271 fire write-action boundary listeners for outermost WA relative to the suspending-WA base

During a suspending write action the outer write action is downgraded to a
write-intent lock and `myWriteStackBase` is advanced past it, but the outer WA
is not popped from `myWriteActionsStack`. A write action that becomes pending
inside that window is the outermost write action relative to the base, yet the
four before-start/after-finished boundary guards tested
`myWriteActionsStack.isEmpty()` -- which is false because the downgraded outer WA
is still on the stack. `beforeWriteActionStart` was therefore skipped, so
`ProgressIndicatorUtilService` never cancelled reads running with write-action
priority. The pending write upgrade then waited forever on the never-draining
read, observed in the wild as an IDE freeze where a "Scanning" thread kept
calling ProgressManager.checkCanceled() under runInReadActionWithWriteActionPriority
while the EDT was stuck starting a write action.

Fix: gate the boundary listeners on `myWriteActionsStack.size == myWriteStackBase`
(new `isOutermostWriteAction()` helper) instead of an empty stack. At top level
the base is 0, so behavior is unchanged; only the suspending-WA window is fixed.

Adds a regression test in BackgroundWriteActionTest that fails before the fix
(the write-priority read is never cancelled) and passes after.

M-Session-Id: M-c931d72f-abc1-4c65-95ec-5611e404d28c
(cherry picked from commit 8e43e5644e5bb85b20c355cda943dd57127c5e9e)

GitOrigin-RevId: a7ad4e1a06fb198af5f91e45177d33a3e937f075
This commit is contained in:
Maksim Medvedev
2026-08-26 22:46:50 +00:00
committed by intellij-monorepo-bot
parent a140cb8113
commit 35bd5b5823
2 changed files with 66 additions and 4 deletions
@@ -1019,7 +1019,7 @@ class NestedLocksThreadingSupport : ThreadingSupport {
finally {
drainWriteActionFollowups()
writeIntentInitResult.release()
if (myWriteActionsStack.isEmpty()) {
if (isOutermostWriteAction()) {
fireAfterWriteActionFinished(writeIntentInitResult.listeners, clazz)
}
}
@@ -1033,7 +1033,7 @@ class NestedLocksThreadingSupport : ThreadingSupport {
return proceedWithSuspendWriteLockAcquisitionFromWriteIntent(computationState, writeIntentInitResult, clazz, computation)
}
finally {
if (myWriteActionsStack.isEmpty()) {
if (isOutermostWriteAction()) {
fireAfterWriteActionFinished(writeIntentInitResult.listeners, clazz)
}
}
@@ -1105,7 +1105,7 @@ class NestedLocksThreadingSupport : ThreadingSupport {
}
finally {
cleanup()
if (myWriteActionsStack.isEmpty()) {
if (isOutermostWriteAction()) {
fireAfterWriteActionFinished(frozenListeners, clazz)
}
}
@@ -1199,7 +1199,7 @@ class NestedLocksThreadingSupport : ThreadingSupport {
startPendingWriteAction(state)
if (myWriteActionsStack.isEmpty()) {
if (isOutermostWriteAction()) {
fireBeforeWriteActionStart(frozenListeners, clazz)
}
return frozenListeners
@@ -1311,6 +1311,18 @@ class NestedLocksThreadingSupport : ThreadingSupport {
}
}
/**
* A write action is the outermost one when the write-action stack holds no entries above [myWriteStackBase].
*
* In the common case [myWriteStackBase] is `0`, so this is equivalent to [myWriteActionsStack] being empty.
* During a suspending write action the outer write action is downgraded to a write-intent lock and the base is
* advanced past it (see [downgradeWriteLockToWriteIntent]) without popping it from the stack; a write action that
* starts inside that window is therefore outermost relative to the base even though the stack is not literally
* empty. The outermost-boundary listeners ([fireBeforeWriteActionStart]/[fireAfterWriteActionFinished]) must fire
* for it so that write-action-priority reads get cancelled.
*/
private fun isOutermostWriteAction(): Boolean = myWriteActionsStack.size == myWriteStackBase
fun downgradeWriteLockToWriteIntent(): AccessToken {
val state = getComputationState()
val permit = state.getThisThreadPermit()
@@ -20,6 +20,7 @@ import com.intellij.openapi.application.useBackgroundWriteAction
import com.intellij.openapi.application.useTrueSuspensionForWriteAction
import com.intellij.openapi.components.service
import com.intellij.openapi.progress.Cancellation
import com.intellij.openapi.progress.ProcessCanceledException
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.progress.runBlockingCancellable
import com.intellij.openapi.progress.util.ProgressIndicatorUtils
@@ -558,6 +559,55 @@ class BackgroundWriteActionTest {
}
}
/**
* Regression test for a missed write-action-priority cancellation during a suspending write action.
*
* A suspending write action ([com.intellij.openapi.application.ThreadingSupport.executeSuspendingWriteAction])
* temporarily downgrades the outer write action to a write-intent lock and advances the write-stack base past it. A
* write action that becomes pending inside that window is the outermost write action relative to the base, so it must
* fire `beforeWriteActionStart` -- the event [com.intellij.openapi.progress.util.ProgressIndicatorUtilService] uses to
* cancel read actions running with write-action priority.
*
* The firing used to be gated on a literally-empty write-action stack, which is never empty during the window (the
* downgraded outer write action is still on the stack). As a result the write-priority read was never canceled and the
* pending write upgrade waited forever on the never-draining read -- observed in the wild as an IDE freeze where a
* "Scanning" thread kept calling [ProgressManager.checkCanceled] under `runInReadActionWithWriteActionPriority` while
* the EDT was stuck trying to start a write action.
*/
@Suppress("DEPRECATION")
@Test
fun `write action pending inside a suspending write action cancels a write-priority read`(): Unit =
timeoutRunBlocking(context = Dispatchers.Default, timeout = 30.seconds) {
val readStarted = Job()
val readCanceled = AtomicBoolean(false)
edtWriteAction {
// downgrades the outer (EDT) write action to a write-intent lock and advances the write-stack base past it
getGlobalThreadingSupport().executeSuspendingWriteAction {
launch(Dispatchers.Default) {
ProgressIndicatorUtils.runInReadActionWithWriteActionPriority({
readStarted.complete()
// spin with write-action priority; a pending write action must cancel this read.
// bounded so that on the buggy code path the test fails with a clear assertion instead of hanging.
val deadlineNs = System.nanoTime() + 15.seconds.inWholeNanoseconds
try {
while (System.nanoTime() < deadlineNs) {
ProgressManager.checkCanceled()
}
}
catch (_: ProcessCanceledException) {
readCanceled.set(true)
}
}, null)
}
// make sure the read holds a read permit and registered its write-action-priority cancellation
readStarted.asCompletableFuture().join()
// this write action becomes pending during the downgrade window; it must cancel the read above
application.runWriteAction { }
}
}
assertTrue(readCanceled.get(), "the write-priority read must be canceled by the write action pending inside the suspending write action")
}
/**
* This test is not set in stone; if you feel that the platform is ready to block same-level read actions, the feel free to adjust the test.
*/