| name | platform-coroutines-structured-concurrency |
| description | Write or review IntelliJ Kotlin coroutine code for structured concurrency: scope ownership, cancellation, failure propagation, leaked coroutines. |
Coroutines: structured concurrency
Keep cancellation, failures, and lifetimes predictable in Kotlin coroutine code across any
IntelliJ-based product module. Three rules govern every decision:
- Hierarchy โ which scope owns this coroutine, and when is that scope cancelled.
- Propagation โ what a cancellation or failure does to parents, siblings, and children.
- 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
- 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.
- 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.
- Check every
catch on the path for a swallowed CancellationException / ProcessCanceledException.
- 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:
val scope = CoroutineScope(someContextElement.asContextElement())
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.
init { scope.launch { observe() } }
fun start() { scope.launch { observe() } }
-
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) {
throw c
} 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:
- 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.
- 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.
- 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.
| Pattern | Default | Smallest fix |
|---|
GlobalScope for feature work | Critical | child of a lifecycle scope |
Detached CoroutineScope never cancelled | Critical | childScope of an owning scope |
Swallowed CancellationException/ProcessCanceledException | Critical | rethrow cancellation before catch (Throwable) |
| Non-cooperative infinite/long loop | Critical | add ensureActive()/yield/suspension point |
runCatching/try around launch/async to contain failure | Critical | supervisorScope + handle inside child |
launch in init with a scope tied to own Disposer registration | Major | start() after registration |
Manual scope where childScope fits; scope not tied to lifecycle | Major | inject/childScope; register Disposable before launch |
invokeOnCompletion mutating shared state unsynchronized or that can throw | Major | compareAndSet, or clean up in coroutine finally |
Self-cancellation (cancel() on current job) | Major | return / throw CancellationException |
| Promise/callback bridge left pending on cancellation | Major | complete/cancel external primitive before rethrow |
Unnecessary invokeOnCompletion bookkeeping | Minor | remove it |
blockingContext wrapper (deprecated 2024.2; context now implicit) | Minor | delete the wrapper |
| Cancellation reported as a user-visible error | Minor | rethrow silently |
supervisorScope/SupervisorJob where a plain scope suffices | Minor | use 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.