Kotlin Coroutines skill for Codex
Structured concurrency that cancels cleanly. Codex gives every coroutine an owner, keeps suspend functions main-safe with injected dispatchers, and rewrites catch blocks and runCatching that swallow cancellation. Flows are shared with stateIn or shareIn instead of restarting per collector, one-off events stop riding on StateFlow, and tests run on virtual time with runTest and Turbine rather than real delays.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- kotlin-coroutines Installs to ~/.agents/skills/kotlin-coroutines/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install kotlin-coroutines The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project kotlin-coroutines Installs to .agents/skills/kotlin-coroutines/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/kotlin-coroutines.zipunzip -o kotlin-coroutines.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) kotlin-coroutines PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $kotlin-coroutines, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and fixes Kotlin coroutine code with structured concurrency - coroutineScope, supervisorScope, viewModelScope and lifecycle-aware collection, dispatchers and withContext, cancellation that is never swallowed, exception propagation, Flow operators, StateFlow and SharedFlow with stateIn, callbackFlow, and deterministic tests with runTest and Turbine. Use when building Android or Ktor code with suspend functions, when coroutines leak, hang or crash the app, when a Flow emits too often or never, or when tests with delays are flaky.
Use it when
- Writing suspend functions, Flows or ViewModels in Android, Ktor or server Kotlin
- Coroutines leak, keep running after cancellation, or crash the parent unexpectedly
- A StateFlow or SharedFlow emits too often, too rarely, or restarts work per collector
- Coroutine tests rely on real time or Thread.sleep
Use something else when
- The question is general Kotlin style, null safety or data classes: use kotlin-idioms
- You are structuring Android layers and dependency injection: use android-architecture
How to check the result
Every launch has an owning scope, no catch block in suspend code drops CancellationException, and runTest tests pass on virtual time with no real sleeps.
The mistake it prevents
Wrapping suspend calls in runCatching or catch (e: Exception), which swallows CancellationException and keeps cancelled work running.
Done well means: every coroutine runs in a scope that is cancelled with its owner (screen,
request, service), no code catches CancellationException without rethrowing, blocking
work runs on Dispatchers.IO or a dedicated dispatcher, and tests run on virtual time
with no real sleeps.
Before writing code
- Check
kotlinx-coroutines-*versions inlibs.versions.tomlor build files; keep core, android and test artifacts on the same version. - Identify the owner of each coroutine:
viewModelScope,lifecycleScope, a Ktorcall/application scope, or a class-ownedCoroutineScope(SupervisorJob() + dispatcher)with aclose()that cancels it. - Read
AGENTS.mdfor the project’s dispatcher injection and testing conventions.
Scopes and builders
| Need | Use |
|---|---|
| Run children in parallel, fail together | coroutineScope { } with async/launch |
| Children independent; one failing must not cancel siblings | supervisorScope { } or a SupervisorJob |
| Switch thread for a block and return a value | withContext(Dispatchers.IO) { } |
| UI-bound work in Android | viewModelScope.launch |
| Collect a Flow in an Activity/Fragment | lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { flow.collect { } } } |
| Collect in Compose | flow.collectAsStateWithLifecycle() |
| App-wide background work that outlives screens | An injected application CoroutineScope, or WorkManager for guaranteed work |
| Fire-and-forget from anywhere | Never GlobalScope; find the owner |
suspend fun loadDashboard(userId: String): Dashboard = coroutineScope {
val profile = async { api.profile(userId) }
val orders = async { api.orders(userId) }
Dashboard(profile.await(), orders.await()) // if one fails, the other is cancelled
}
Dispatchers
Dispatchers.Main: UI only.Dispatchers.Default: CPU work (parsing, sorting, diffing).Dispatchers.IO: blocking I/O (JDBC, files, legacy SDKs).- Suspend functions should be main-safe: the function that performs blocking work switches context itself, callers do not.
- Limit concurrency against a constrained resource with
Dispatchers.IO.limitedParallelism(4)or aSemaphore, not a custom thread pool. - Inject dispatchers (constructor parameter) so tests can substitute a test dispatcher.
Cancellation
Cancellation is cooperative and signalled with CancellationException.
// Bad: swallows cancellation, coroutine keeps running after its scope is gone
try { repo.sync() } catch (e: Exception) { log(e) }
val r = runCatching { repo.sync() } // same problem
// Good
try {
repo.sync()
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
log(e)
}
- Long CPU loops call
ensureActive()(oryield()) each iteration. - Cleanup that must suspend goes in
finally { withContext(NonCancellable) { ... } }. withTimeoutthrowsTimeoutCancellationException(aCancellationException), which can be mistaken for parent cancellation; preferwithTimeoutOrNulland handlenull.- Blocking Java calls do not observe cancellation; wrap with
runInterruptible(Dispatchers.IO) { }when the call responds to thread interrupts.
Exceptions
- In
launch, an uncaught exception cancels the parent scope (unless supervised) and goes to theCoroutineExceptionHandlerof the root coroutine. - In
async, the exception is thrown atawait()and also cancels the parent in a regular scope. Wrappingawait()in try/catch does not stop the parent being cancelled; usesupervisorScopewhen failures should be isolated. CoroutineExceptionHandleronly works on root coroutines (direct children of the scope); installing it on a nestedlaunchhas no effect.
Flow
class SearchViewModel(private val repo: Repo) : ViewModel() {
private val query = MutableStateFlow("")
val results: StateFlow<UiState> = query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q -> if (q.isBlank()) flowOf(UiState.Empty) else repo.search(q).map(UiState::Data) }
.catch { emit(UiState.Error(it)) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Empty)
fun onQuery(q: String) { query.value = q }
}
| Need | Operator |
|---|---|
| Cancel previous work when a new value arrives | flatMapLatest, collectLatest, mapLatest |
| Move upstream work off the main thread | flowOn(Dispatchers.IO) (affects upstream only) |
| Combine latest values of several flows | combine |
| Drop intermediate values under backpressure | conflate() |
| Share one upstream among many collectors | stateIn (has a current value) / shareIn (events, replay n) |
| Wrap a callback API | callbackFlow { ...; awaitClose { unregister() } } |
| Retry with backoff | retryWhen { cause, attempt -> cause is IOException && attempt < 3 && run { delay(1000L shl attempt.toInt()); true } } |
StateFlowconflates and skips equal values (usesequals); emitting the same data class twice does nothing. UseSharedFlowor aChannelfor one-off events (navigation, snackbars), or model them as state the UI consumes and clears.- Update
MutableStateFlowatomically withstate.update { it.copy(loading = false) }, notstate.value = state.value.copy(...)from several coroutines. catchonly handles upstream exceptions; an exception incollect {}is not caught by it.- Cold flows restart on every collection; repeated network calls usually mean a missing
stateIn/shareIn.
Testing
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModelTest {
private val dispatcher = StandardTestDispatcher()
@BeforeTest fun setUp() { Dispatchers.setMain(dispatcher) }
@AfterTest fun tearDown() { Dispatchers.resetMain() }
@Test fun `debounces queries`() = runTest(dispatcher) {
val vm = SearchViewModel(FakeRepo())
vm.results.test { // Turbine
assertEquals(UiState.Empty, awaitItem())
vm.onQuery("ko"); vm.onQuery("kotlin")
advanceTimeBy(301)
assertEquals(UiState.Data(listOf("kotlin")), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
}
runTestskipsdelayusing virtual time; never useThread.sleepor real timeouts.- Pass the same scheduler to injected dispatchers (
StandardTestDispatcher(testScheduler)) so all coroutines share virtual time. UnconfinedTestDispatcherruns eagerly; good for simple collectors, but it hides ordering bugs. Default toStandardTestDispatcherandadvanceUntilIdle().
Gotchas
GlobalScope.launchandCoroutineScope(Dispatchers.IO).launchin a function create orphan coroutines that leak and are untestable.- Collecting a Flow with
lifecycleScope.launchwithoutrepeatOnLifecyclekeeps collecting while the app is in the background. runBlockingon the Android main thread or in a Ktor handler blocks a thread the system needs; only use it inmainfunctions and legacy bridges.- A
Mutexfrom kotlinx.coroutines is not reentrant; locking it again in the same coroutine deadlocks. asyncwhose result is never awaited still cancels its parent on failure, but under a supervisor its exception is silently lost; uselaunchwhen there is no result.
Verification
./gradlew test # or :module:testDebugUnitTest on Android
./gradlew detekt lint # if configured
Check that every new launch has an owning scope, every catch (e: Exception) in suspend
code rethrows CancellationException, and tests use runTest.
Report template
**Scope fixes:** 3 GlobalScope launches -> injected appScope / viewModelScope
**Cancellation:** 2 runCatching blocks around suspend calls rewritten to rethrow
**Flow:** search results now stateIn(WhileSubscribed(5s)); duplicate network calls removed
**Tests:** 7 runTest + Turbine tests, all virtual time; ./gradlew test green What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Kotlin Coroutines"
short_description: "Structured concurrency that cancels cleanly"
default_prompt: "$kotlin-coroutines Our ViewModel keeps running network calls after the screen closes, fix the coroutine scopes" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.