Back to Intellij Community

Cancellation Model

docs/notebooks/2-CancellationModel.ipynb

2025.3-rc-210.8 KB
Original Source

Cancellation Model

kotlin
%use intellij-platform
import com.intellij.platform.ide.progress.ModalTaskOwner
import com.intellij.platform.ide.progress.runWithModalProgressBlocking
import com.intellij.util.application
import kotlinx.coroutines.delay
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.runReadAction
import com.intellij.openapi.application.runWriteAction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.time.measureTime
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.job
import com.intellij.openapi.progress.ProcessCanceledException
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.vfs.VfsUtilCore
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.openapi.vfs.VirtualFileVisitor
import com.intellij.openapi.progress.runBlockingCancellable
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import com.intellij.openapi.progress.EmptyProgressIndicator
import kotlinx.coroutines.GlobalScope




In this notebook, we will descibe the Cancellation Model of IntelliJ Platform alonside some examples of how to use it.

Motivation

First of all, let's figure out why cancellation is important. An intuitive reason can be that some processes are long, and they need to be canceled if the user gets impatient:

kotlin
application.invokeAndWait {
  runWithModalProgressBlocking(ModalTaskOwner.guess(), "A long process which is not cancellable for some time") {
    Thread.sleep(1000) // this is not cancellable
    delay(5.seconds) // but this can be canceled
  }
}

There is also another reason, which is more important. The threading model in IntelliJ Platform is built around a read/write lock. Over time, the locking API moved towards transactional semantics: write operations are meant to invalidate the results of read operations, so read operations try to terminate quickly if someone wants to initiate a write. To promptly react to this termination request, the code needs to check cancellation frequently enough.

The takeaway here is that almost every area in the platform needs to be prepared to get canceled

kotlin
runBlocking(Dispatchers.Default) {
  launch {
    runReadAction {
      DISPLAY("Read action starting")
      Thread.sleep(1000) // some long non-cancellable operation
      DISPLAY("Read action ending")
    }
  }
  delay(100)
  launch(Dispatchers.EDT) {
    runWriteAction { // you may experience a UI freeze because this write action cannot start
      DISPLAY("Write action is executing")
    }
  }
}

Cancellation in Coroutines

The Platform's cancellation model is inspired by Structured Concurrency of Kotlin Coroutines. There, they have a class Job which represents a descriptor of a computation. Jobs can be canceled and organized in tree-like structures:

kotlin
runBlocking(Dispatchers.Default) {
  lateinit var innerJob1: Job
  lateinit var innerJob2: Job
  val enclosingJob = launch {
    val parentJob = coroutineContext.job
    launch {
      innerJob1 = coroutineContext.job
      DISPLAY("Current job has a parent: ${innerJob1.parent}, which is ${parentJob}")
      awaitCancellation()
    }
    launch {
      innerJob2 = coroutineContext.job
      DISPLAY("Current job also has a parent: ${innerJob2.parent} which is ${parentJob}")
      awaitCancellation()
    }
  }
  delay(500)
  DISPLAY("Now we can cancel the enclosing job: ${enclosingJob}, and it will cancel all its children")
  enclosingJob.cancelAndJoin()
  DISPLAY("Parent job is canceled: ${enclosingJob}")
  DISPLAY("Inner job 1 is canceled: ${innerJob1}")
  DISPLAY("Inner job 2 is canceled: ${innerJob2}")
}

Cancellation in IntelliJ Platform

There are two cancellation models in IntelliJ Platform: Job-based and ProgressIndicator-based. The latter is considered legacy, as it suffers from architectural flaws and offers poor integration with Kotlin Coroutines. In the following text, we will focus on the Job-based cancellation.

Cancellation in the Platform is checked with the globally available function ProgressManager.checkCanceled(). If the Platform decides that the currently executing code should be aborted, then ProgressManager.checkCanceled() throws an instance of ProcessCanceledException. ProcessCanceledException is an inheritor of CancellationException, so it would also cancel the enclosing coroutines.

kotlin
runBlocking(Dispatchers.Default) {
  val job = launch {
    try {
      while (true) {
        ProgressManager.checkCanceled()
      }
    }
    catch (e: ProcessCanceledException) {
      DISPLAY("Caught a ProcessCanceledException: ${e}")
      throw e
    }
  }
  delay(100)
  job.cancel()
}

The majority of the Platform functions already contain checkCanceled() in necessary places. Every time you pass control back to the Platform, it checks in hot places cancellation.

kotlin
runBlocking(Dispatchers.Default) {
  val job = launch {
    while (true) {
      VfsUtilCore.visitChildrenRecursively(VirtualFileManager.getInstance().findFileByNioPath(notebook.workingDir)!!,
                                           object : VirtualFileVisitor<Any>() {
                                             override fun visitFile(file: VirtualFile): Boolean {
                                               return true
                                             }
                                           })
    }
  }
  delay(100)
  job.cancel()
}

As you could notice in the examples above, Job-based cancellation is seamlessly integrated with coroutines. This is because it is built on top of Context Propagation (this particular application is called Cancellation Propagation), which is designed to be coroutine-friendly. The job from Coroutines is automatically installed to thread local, which is later inspected by the Platform. The intended way of transitioning back to coroutines in the Platform is the function runBlockingCancellable. This function is different from runBlocking in a way that it also looks into the thread local with Job (which is located in currentThreadContext()), and runs with it.

kotlin
// here we see that `runBlocking` is not terminated on external cancellation
runBlocking(Dispatchers.Default) {
  val regularBlockingJob = launch {
    runBlocking {
      try {
        withTimeout(1.seconds) {
          while (true) {
            ProgressManager.checkCanceled()
          }
        }
      }
      catch (e: ProcessCanceledException) {
        DISPLAY("Caught a ProcessCanceledException: ${e}")
      }
      catch (e: TimeoutCancellationException) {
        DISPLAY("Caught a TimeoutCancellationException: ${e}")
      }
    }
  }
  delay(100)
  regularBlockingJob.cancel()
}
kotlin
import kotlin.coroutines.cancellation.CancellationException

// but `runBlockingCancellable` actually terminates
runBlocking(Dispatchers.Default) {
  val regularBlockingJob = launch {
    runBlockingCancellable {
      try {
        withTimeout(1.seconds) {
          while (true) {
            ProgressManager.checkCanceled()
          }
        }
      }
      catch (e: CancellationException) {
        DISPLAY("Caught a Cancellation: ${e}")
      }
      catch (e: TimeoutCancellationException) {
        DISPLAY("Caught a TimeoutCancellationException: ${e}")
      }
    }
  }
  delay(100)
  regularBlockingJob.cancel()
}

Basically, there are three cancellation contexts in IntelliJ Platform: coroutines, job-based cancellation and progress-indicator-based cancellation. Some of these contexts require explicit transition. A missing transition can usually result in an accidentally non-cancellable region. Historically, job-based cancellation was called "blocking context", this explains some naming choices.

Here is a table that explains how to perform the transition from one cancellation context to another. The vertical left column means the source context, and the horizontal top row means the destination context. For example, to transition from coroutines to indicators, one should use coroutineToIndicator.

Transition tableCoroutinesJobsProgress indicators
Coroutines-automaticcoroutineToIndicator
JobsrunBlockingCancellable-blockingContextToIndicator
Progress indicatorsrunBlockingCancellableunsupported-

runBlockingCancellable was discussed above. coroutineToIndicator is a helper function that transforms the Job taken from coroutineContext to a ProgressIndicator and runs a computation with this indicator. blockingContextToIndicator does a similar thing, but for the Job taken from currentThreadContext().

Progress Indicators

Progress indicators are a legacy way of managing cancellation in the Platform. The core idea is that we have a descriptor of computation (an instance of ProgressIndicator) which can be installed as a thread-local or passed manually to the necessary computations.

The classical way to execute something under progress indicators is with ProgressManager.executeProcessUnderProgress. This function takes a ProgressIndicator and a computation, and executes it under the indicator.

kotlin
val indicator = EmptyProgressIndicator()
GlobalScope.launch {
  delay(1.seconds)
  indicator.cancel()
}
ProgressManager.getInstance().runProcess(
  {
    val currentTime = measureTime {
      try {
        while (true) {
          ProgressManager.checkCanceled()
        }
      }
      catch (e: ProcessCanceledException) {
        DISPLAY("Canceled!")
      }
    }
    DISPLAY("Process took $currentTime until cancellation")
  }, indicator)

There are multiple methods in ProgressManager that allow running computations with indicators, with possibility to configure synchronous or asynchronous execution.

There are several problems with ProgressIndicator:

  1. This class is not responsible just for cancellation, but also for progress reporting. It also contains a lot of unrelated methods which are not needed in such a supposedly lightweight entity.
  2. It plays badly with coroutines and structured concurrency. One of the principles that we strive to achieve in IntelliJ Platform is to have a hierarchy of computations so that unnecessary computations can promptly free the resources they occupy. Coroutines do great job with it -- all computations are organized in tree-like structures, and they can be canceled or tracked. Progress indicators were designed for invokeLater-based computations, and an instance of progress indicator may easily outlive the computation where it was created.