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.

  • Languages
  • $kotlin-coroutines
  • 185 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- kotlin-coroutines

Installs to ~/.agents/skills/kotlin-coroutines/ for every project on this machine.

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.

~/.agents/skills/kotlin-coroutines/SKILL.md

--- name: kotlin-coroutines description: Writes and fixes Kotlin coroutine code with structured concurrency - coroutineScope, supervisorScope, viewModelScope and lifecycle-aware collection,… license: MIT ---

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 in libs.versions.toml or build files; keep core, android and test artifacts on the same version.
  • Identify the owner of each coroutine: viewModelScope, lifecycleScope, a Ktor call/application scope, or a class-owned CoroutineScope(SupervisorJob() + dispatcher) with a close() that cancels it.
  • Read AGENTS.md for the project’s dispatcher injection and testing conventions.

Scopes and builders

NeedUse
Run children in parallel, fail togethercoroutineScope { } with async/launch
Children independent; one failing must not cancel siblingssupervisorScope { } or a SupervisorJob
Switch thread for a block and return a valuewithContext(Dispatchers.IO) { }
UI-bound work in AndroidviewModelScope.launch
Collect a Flow in an Activity/FragmentlifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { flow.collect { } } }
Collect in Composeflow.collectAsStateWithLifecycle()
App-wide background work that outlives screensAn injected application CoroutineScope, or WorkManager for guaranteed work
Fire-and-forget from anywhereNever 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 a Semaphore, 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() (or yield()) each iteration.
  • Cleanup that must suspend goes in finally { withContext(NonCancellable) { ... } }.
  • withTimeout throws TimeoutCancellationException (a CancellationException), which can be mistaken for parent cancellation; prefer withTimeoutOrNull and handle null.
  • 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 the CoroutineExceptionHandler of the root coroutine.
  • In async, the exception is thrown at await() and also cancels the parent in a regular scope. Wrapping await() in try/catch does not stop the parent being cancelled; use supervisorScope when failures should be isolated.
  • CoroutineExceptionHandler only works on root coroutines (direct children of the scope); installing it on a nested launch has 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 }
}
NeedOperator
Cancel previous work when a new value arrivesflatMapLatest, collectLatest, mapLatest
Move upstream work off the main threadflowOn(Dispatchers.IO) (affects upstream only)
Combine latest values of several flowscombine
Drop intermediate values under backpressureconflate()
Share one upstream among many collectorsstateIn (has a current value) / shareIn (events, replay n)
Wrap a callback APIcallbackFlow { ...; awaitClose { unregister() } }
Retry with backoffretryWhen { cause, attempt -> cause is IOException && attempt < 3 && run { delay(1000L shl attempt.toInt()); true } }
  • StateFlow conflates and skips equal values (uses equals); emitting the same data class twice does nothing. Use SharedFlow or a Channel for one-off events (navigation, snackbars), or model them as state the UI consumes and clears.
  • Update MutableStateFlow atomically with state.update { it.copy(loading = false) }, not state.value = state.value.copy(...) from several coroutines.
  • catch only handles upstream exceptions; an exception in collect {} 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()
        }
    }
}
  • runTest skips delay using virtual time; never use Thread.sleep or real timeouts.
  • Pass the same scheduler to injected dispatchers (StandardTestDispatcher(testScheduler)) so all coroutines share virtual time.
  • UnconfinedTestDispatcher runs eagerly; good for simple collectors, but it hides ordering bugs. Default to StandardTestDispatcher and advanceUntilIdle().

Gotchas

  • GlobalScope.launch and CoroutineScope(Dispatchers.IO).launch in a function create orphan coroutines that leak and are untestable.
  • Collecting a Flow with lifecycleScope.launch without repeatOnLifecycle keeps collecting while the app is in the background.
  • runBlocking on the Android main thread or in a Ktor handler blocks a thread the system needs; only use it in main functions and legacy bridges.
  • A Mutex from kotlinx.coroutines is not reentrant; locking it again in the same coroutine deadlocks.
  • async whose result is never awaited still cancels its parent on failure, but under a supervisor its exception is silently lost; use launch when 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem