platform-coroutines-structured-concurrency
GitHub用于 Kotlin 协程结构化并发代码的编写与审查,规范作用域所有权、取消传播及生命周期管理,防止资源泄漏。
Trigger Scenarios
Install
npx skills add DetachHead/rebased --skill platform-coroutines-structured-concurrency -g -y
SKILL.md
Frontmatter
{
"name": "platform-coroutines-structured-concurrency",
"description": "Write or review IntelliJ coroutine scope ownership and cancellation."
}
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
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.
Version History
- 2af32ff Current 2026-09-22 01:03


