Coroutines: structured concurrency

SkillDev tools

Helps your agent write or review Kotlin coroutine scope ownership and cancellation code in IntelliJ projects.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Coroutines: structured concurrency skill

About this capability

Write or review IntelliJ coroutine scope ownership and cancellation.

What this skill tells your AI

The instructions your AI receives, as published by jetbrains/intellij-community in .agents/skills/platform-coroutines-structured-concurrency/SKILL.md and read by ahel’s review.

Keep cancellation, failures, and lifetimes predictable in Kotlin coroutine code across any IntelliJ-based product module. Three rules govern every decision:

  1. Hierarchy — which scope owns this coroutine, and when is that scope cancelled.
  2. Propagation — what a cancellation or failure does to parents, siblings, and children.
  3. Don't be creative — prefer standard primitives (coroutineScope, supervisorScope, a lifecycle-bound scope) over hand-rolled scope wiring, the most common source of leaks.

Reach for this skill when changing suspend functions, launch/async, CoroutineScope construction, cancellation handling, or Flow collection — and when diagnosing leaked coroutines, uncancellable loops, swallowed cancellation, GlobalScope, launch-in-init, detached scopes, invokeOnCompletion, ProcessCanceledException, blocking/progress bridges, ModalityState context capture, or promise/callback cancellation bridges.

Related skills: platform-deep-dives (coroutine internals, dispatchers, read/write actions); kotlin-ui-swing-component-architecture (Swing UI and EDT ownership).

Core review loop

  1. Name the owning CoroutineScope and the exact moment it is cancelled (Disposable disposal, service teardown, explicit cancel()). If you cannot name that moment, that is the finding.
  2. Check long-running, CPU-bound, blocking, or looped coroutine bodies (and Flow collectors) for a cooperative cancellation checkpoint — do not flag short bodies that already suspend.
  3. Check every catch on the path for a swallowed CancellationException / ProcessCanceledException.
  4. Check any invokeOnCompletion, GlobalScope, manual CoroutineScope(...), or launch in init against the rules below, then classify (severity table) and propose the smallest structural fix.

Scope ownership and lifecycle

  • Never use GlobalScope for feature work — it is never cancelled, so its coroutines outlive the component that started them.

  • Prefer an injected or platform lifecycle scope (project/service scope, or the scope handed to you). A standalone CoroutineScope(context) with no Job in the context gets a fresh root Job() not linked to any parent — nothing cancels or awaits it, so it leaks:

    // Wrong: detached root scope; cleanup depends on a manual cancel() that is easy to miss.
    val scope = CoroutineScope(someContextElement.asContextElement())
    
    // Right: child of an owning scope; cancelled structurally with its parent.
    val scope = owner.coroutineScope.childScope("MyFeature", someContextElement.asContextElement())
    
  • If a manual scope is unavoidable, either make it a child of an owning scope, or register the owning Disposable (cancel the scope in dispose()) before launching any work, and verify that registration actually runs. Disposal-only cancellation with no parent link is fragile — a missed dispose() leaks everything the scope launched.

  • launch in init {} — depends on what cancels the scope. If the object owns a scope tied to its own Disposer registration, don't launch in init: the coroutine can run (touching a half-built this) before registration completes and leak on failure — expose start() and call it after Disposer.register. A @Service with an injected CoroutineScope is the exception: that scope is cancelled with the service's container (app/project/plugin) by the platform — not via your dispose(), and no Disposable is needed solely to cancel the injected scope — so a service self-starting workers from init is acceptable. Just keep the constructor cheap and don't read not-yet-initialized state.

    // Owned scope, cancelled via your own Disposer registration:
    init { scope.launch { observe() } }          // Wrong — may run before Disposer.register completes
    fun start() { scope.launch { observe() } }   // Right — after Disposer.register(owner, this)
    // @Service(private val scope: CoroutineScope) — self-start from init is fine; scope is platform-managed.
    
  • Prefer the shortest lifecycle that consumes the work; use an app/project service scope only when the result is genuinely owned for that whole lifetime.

  • Do not store per-submission UI context in a long-lived scope. Context elements such as current ModalityState must be captured at the submit site, not at service/object construction. Avoid CoroutineScope(ModalityState.defaultModalityState().asContextElement()); prefer scope.launch(currentModality.asContextElement()) { ... } when modality belongs to that operation. Modality dispatch semantics belong to kotlin-ui-swing-component-architecture / platform-deep-dives.

Job hierarchy and failure propagation

  • Cancelling a parent cancels all children recursively; a child failing with anything other than CancellationException cancels its parent and siblings. SupervisorJob/supervisorScope opts child failure out of cancelling the parent; parent cancellation still cancels supervised children.
  • A parent that finished its block is completing, not done, until every child completes.
  • coroutineScope { } is all-or-nothing (one failure fails the scope and rethrows); supervisorScope { } is only for genuinely independent children.

Cooperative cancellation

  • A loop/CPU-bound body with no suspension point is not cancellable and hangs on cancel(). Add a suspension point (delay, yield), ensureActive(), or checkCanceled() in progress-aware code.
  • Callback APIs: bridge with suspendCancellableCoroutine (not suspendCoroutine) and release the resource in invokeOnCancellation.
  • Blocking APIs: use a blocking-appropriate context (Dispatchers.IO) or, for progress-aware blocking code, coroutineToIndicator { indicator -> ... }. Pair cancellation with an explicit interrupt/close strategy; coroutine cancellation alone does not abort an in-progress blocking call. Do not wrap blocking code in blockingContext: deprecated since 2024.2 because context is installed implicitly (ReplaceWith("action()")).

Exception and failure propagation

  • runCatching { launch { ... } } does not catch the child's failure. The child fails its parent scope out-of-band while launch returns normally, so try/runCatching sees nothing. To isolate a child, use supervisorScope and handle the failure inside the child.

  • The same trap applies to async: try/runCatching around async { } does not contain a later failure — the exception surfaces at await(), and an unsupervised failure may already have cancelled the parent before you await.

  • Never swallow cancellation. runCatching and bare catch (e: Throwable) catch CancellationException too; swallowing it breaks structured concurrency and can hang cancellation. ProcessCanceledException is a CancellationException subtype, so catching cancellation covers it too. Rethrow cancellation first:

    try {
      body()
    } catch (c: CancellationException) {   // also covers ProcessCanceledException (a subtype)
      throw c                              // never swallow cancellation
    } catch (x: Throwable) {
      handle(x)
    }
    
  • When bridging to a non-coroutine promise/callback, settle it before rethrowing cancellation. If a coroutine owns an AsyncPromise or a callback result, cancellation can skip the normal result path and leave external awaiters pending. In catch (c: CancellationException), complete the external primitive (setError(c) / cancel) before throw c. Plain Deferred does not need this — cancellation completes it.

  • Do not report a caught CancellationException as a user-visible error, even if you rethrow it.

Don't cancel yourself

Do not call cancel() on the coroutine you are currently running in — it does not stop execution immediately (only at the next suspension point) and poisons the surrounding scope. Return early or throw CancellationException. A shared suspend fun must never cancel its caller's job.

invokeOnCompletion

Prefer to avoid it. Three failure modes make it a trap:

  1. Runs concurrently, unordered, on an unspecified thread — not guaranteed on the EDT or after your surrounding code. A check-then-act on a shared field races; use AtomicReference.compareAndSet.
  2. Retained until the job completes — registering on a long-lived job (or in a loop) accumulates handlers and leaks. Only register on short-lived jobs that actually finish.
  3. Must not throw — exceptions from handlers are reported through coroutine exception handling/logging, not to the caller waiting for completion. Keep the body to trivial, non-failing bookkeeping.

Preferred alternative — do completion work as the last step inside the coroutine:

scope.launch {
  try { doWork() }
  finally { withContext(NonCancellable + Dispatchers.EDT) { onFinished() } }
}

Use NonCancellable only for small, bounded cleanup that must run even after cancellation — never wrap substantial work in it, and keep UI cleanup lifecycle/disposal-guarded (do not touch a disposed component). Cancelling an already-completed Job is a no-op, so "clear my job handle on completion" bookkeeping is often unnecessary — verify it changes behavior before adding it.

Severity defaults

Adjust for actual impact.

PatternDefaultSmallest fix
GlobalScope for feature workCriticalchild of a lifecycle scope
Detached CoroutineScope never cancelledCriticalchildScope of an owning scope
Swallowed CancellationException/ProcessCanceledExceptionCriticalrethrow cancellation before catch (Throwable)
Non-cooperative infinite/long loopCriticaladd ensureActive()/yield/suspension point
runCatching/try around launch/async to contain failureCriticalsupervisorScope + handle inside child
launch in init with a scope tied to own Disposer registrationMajorstart() after registration
Manual scope where childScope fits; scope not tied to lifecycleMajorinject/childScope; register Disposable before launch
invokeOnCompletion mutating shared state unsynchronized or that can throwMajorcompareAndSet, or clean up in coroutine finally
Self-cancellation (cancel() on current job)Majorreturn / throw CancellationException
Promise/callback bridge left pending on cancellationMajorcomplete/cancel external primitive before rethrow
Unnecessary invokeOnCompletion bookkeepingMinorremove it
blockingContext wrapper (deprecated 2024.2; context now implicit)Minordelete the wrapper
Cancellation reported as a user-visible errorMinorrethrow silently
supervisorScope/SupervisorJob where a plain scope sufficesMinoruse coroutineScope

Further reading

For deep internals see platform-deep-dives (coroutine notebooks: cancellation model, context propagation) and the ultimate coroutine docs docs/IntelliJ-Platform/4_man/Kotlin-Coroutines/, especially 9_Gotchas-and-practices/ and 8_UI-EDT-Dispatchers.md.

Signals

GitHub stars
21k
Forks
6k
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
platform-coroutines-structured-concurrency
Source
github.com/jetbrains/intellij-community