Coroutines: structured concurrency
SkillDev toolsHelps 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.
No other account needed.
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:
- 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
CoroutineScopeand the exact moment it is cancelled (Disposable disposal, service teardown, explicitcancel()). If you cannot name that moment, that is the finding. - Check long-running, CPU-bound, blocking, or looped coroutine bodies (and
Flowcollectors) for a cooperative cancellation checkpoint — do not flag short bodies that already suspend. - Check every
catchon the path for a swallowedCancellationException/ProcessCanceledException. - Check any
invokeOnCompletion,GlobalScope, manualCoroutineScope(...), orlaunchininitagainst the rules below, then classify (severity table) and propose the smallest structural fix.
Scope ownership and lifecycle
-
Never use
GlobalScopefor 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 noJobin the context gets a fresh rootJob()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 indispose()) before launching any work, and verify that registration actually runs. Disposal-only cancellation with no parent link is fragile — a misseddispose()leaks everything the scope launched. -
launchininit {}— depends on what cancels the scope. If the object owns a scope tied to its ownDisposerregistration, don't launch ininit: the coroutine can run (touching a half-builtthis) before registration completes and leak on failure — exposestart()and call it afterDisposer.register. A@Servicewith an injectedCoroutineScopeis the exception: that scope is cancelled with the service's container (app/project/plugin) by the platform — not via yourdispose(), and noDisposableis needed solely to cancel the injected scope — so a service self-starting workers frominitis 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
ModalityStatemust be captured at the submit site, not at service/object construction. AvoidCoroutineScope(ModalityState.defaultModalityState().asContextElement()); preferscope.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
CancellationExceptioncancels its parent and siblings.SupervisorJob/supervisorScopeopts 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(), orcheckCanceled()in progress-aware code. - Callback APIs: bridge with
suspendCancellableCoroutine(notsuspendCoroutine) and release the resource ininvokeOnCancellation. - 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 inblockingContext: 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 whilelaunchreturns normally, sotry/runCatchingsees nothing. To isolate a child, usesupervisorScopeand handle the failure inside the child. -
The same trap applies to
async:try/runCatchingaroundasync { }does not contain a later failure — the exception surfaces atawait(), and an unsupervised failure may already have cancelled the parent before you await. -
Never swallow cancellation.
runCatchingand barecatch (e: Throwable)catchCancellationExceptiontoo; swallowing it breaks structured concurrency and can hang cancellation.ProcessCanceledExceptionis aCancellationExceptionsubtype, 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
AsyncPromiseor a callback result, cancellation can skip the normal result path and leave external awaiters pending. Incatch (c: CancellationException), complete the external primitive (setError(c)/ cancel) beforethrow c. PlainDeferreddoes not need this — cancellation completes it. -
Do not report a caught
CancellationExceptionas 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-acton a shared field races; useAtomicReference.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.
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