mcpField
K
kotlin-coroutines-structured-concurrency

kotlin-coroutines-structured-concurrency

github.com/chrisbanes開発1.1k1.0k 回インストール1 日前に更新
リポジトリ

kotlin-coroutines-structured-concurrency について

Kotlin コルーチンの実装とレビューを支援するスキルです。呼び出し側がスコープを管理し、サスペンド API によってライフサイクル、キャンセル、エラー処理を把握できる形にします。

主な機能

  1. 01スコープ保持などのアンチパターンを特定
  2. 02呼び出し側にコルーチンのライフサイクルを委ねる
  3. 03UI の状態保持層とビジネス層の役割を区別
  4. 04キャンセル例外の再送出を確認
  5. 05バックグラウンド処理の明示的な開始方法を提示

活用シーン

  1. 01リポジトリやユースケースのスコープをレビュー
  2. 02fire-and-forget API をサスペンド API に置き換え
  3. 03スコープのキャンセルによる暗黙の失敗を調査
  4. 04初期化処理やシングルトンのバックグラウンド起動を確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/chrisbanes/skills --skill kotlin-coroutines-structured-concurrency

インストール後、Claude Code で/kotlin-coroutines-structured-concurrencyを入力して読み込まれたか確認します。

詳細

Kotlin コルーチン:構造化並行性

基本原則

適切に構造化されたコルーチンは、自己完結した非同期処理の単位です。呼び出し元で把握できるライフサイクルのスコープ内で、入口と出口がそれぞれ1つずつあります。

スコープは通常、呼び出される側ではなく、呼び出し元のライフサイクルに結び付けるべきです。 CoroutineScope をプロパティとして保持している場合は、レビューで注意すべきサインです。そのクラスは、キャンセル、エラー報告、再起動の動作、ライフサイクルの管理責任を自ら持つことを証明しなければなりません。ほとんどのリポジトリ、マネージャー、ユースケース、データソースはそれを証明できないため、代わりに suspend API を公開するべきです。

解決策はほぼいつも同じです。API を suspend にして、呼び出し元にスコープを管理させます。

このスキルを使う場面

Kotlin コードを記述またはレビューしていて、次のいずれかを見つけた場合です。

  • private val scope: CoroutineScope を持つクラス(コンストラクター引数をプロパティとして保持している)
  • init { scope.launch { ... } } ブロック
  • 本体で scope.launch { ... } を呼び出す、サスペンドしない公開関数
  • サスペンドに対応できるアプリケーションコードでの runBlocking { ... }、または runTest を使うべきテストでの runBlocking { ... }
  • CancellationException を再スローせずに、サスペンド関数呼び出しを囲む runCatching { suspendCall() } や Exception/Throwable に対する catch
  • サスペンド処理を囲み、再スローしない catch (e: CancellationException)(または同等の処理)

気付かれにくいキャンセルのバグ

所有者のいない CoroutineScope プロパティが非常に危険な理由は、スコープが一度キャンセルされると、そのスコープで以降に実行されるすべての launch は、例外もログも何も発生させず、キャンセルされた状態で完了するからです。 処理は実行されません。これは原因の特定が特に難しいコルーチンのバグの1つであり、クラスが所有していないライフサイクルへの長期間有効な参照を保持すると発生します。

API が suspend であれば、この問題は起こりません。呼び出し元のスコープが有効なら処理が実行され、呼び出し元が呼び出し箇所でキャンセルすれば、そのことを呼び出し元が把握できます。

アンチパターンと修正方法

1. CoroutineScope をプロパティとして保持する
// ❌ BAD
@Inject
class UserRepository(
    private val scope: CoroutineScope,
    private val api: UserApi,
) {
    fun refresh() {
        scope.launch { _state.value = api.fetchUser() }
    }
}

// ✅ GOOD
@Inject
class UserRepository(
    private val api: UserApi,
) {
    suspend fun refresh(): User = api.fetchUser()
}

リポジトリは、コルーチンについて知る必要がなくなります。どのスコープを使うか、どのようなエラー処理を行うか、どのようなキャンセルのセマンティクスにするかは、呼び出し元(ViewModel、ユースケース)が決めます。

2. init ブロックでの起動
// ❌ BAD: construction-time side effect, unbounded work
class UserSession(private val scope: CoroutineScope, private val api: Api) {
    init { scope.launch { _user.value = api.load() } }
}

コンストラクターはすぐに戻ります。呼び出し元は読み込みを await できず、エラーを確認できず、キャンセルもできません。クラスは「生きている」ものの、その状態は未定義です。

// ✅ GOOD: explicit bootstrap, caller owns the suspension
class UserSession(private val api: Api) {
    private var _user: User? = null
    val user: User get() = checkNotNull(_user) { "Call init() first" }

    suspend fun init() { _user = api.load() }
}
3. UI 以外のクラスからのファイア・アンド・フォーゲット

UI 以外のクラス(リポジトリ、マネージャー、ユースケース、データソース)にある、サスペンドしない公開関数が、クラス所有のスコープで処理を起動するパターンです。呼び出し元は結果もエラーも受け取れず、キャンセルもできず、処理が実行された保証もありません。

// ❌ BAD — repository with stored scope and fire-and-forget public API
class AnalyticsClient(private val scope: CoroutineScope, private val api: Api) {
    fun track(event: Event) {
        scope.launch { api.send(event) }      // caller has no idea what happens
    }
    fun signOut() {
        scope.launch { api.signOut() }        // silent failure if scope cancelled
    }
}
// ✅ GOOD
class AnalyticsClient(private val api: Api) {
    suspend fun track(event: Event) = api.send(event)
    suspend fun signOut() = api.signOut()
}
例外:UI ↔ 状態保持者の境界

UI フレームワークではサスペンドできません。Composable の onClick、Fragment の onKeyEvent、Activity の onNewIntent は、いずれも suspend できません。状態保持者(ViewModel、Decompose Component、feature model など、UI イベントを受け取り、UI 状態を保持する役割を持つもの)が、UI ライフサイクルに結び付いた非同期処理へ一度きりの UI イベントを変換する境界です。それが状態保持者の役目です。

// ✅ GOOD — state holder absorbs a non-suspending UI event onto its scope
class FavouritesViewModel(private val repo: FavouritesRepository) : ViewModel() {
    fun onToggleFavourite(item: Item) {
        viewModelScope.launch { repo.toggleFavourite(item) }
    }
}

// in Compose:
ListItem(onClick = { viewModel.onToggleFavourite(item) })

これはファイア・アンド・フォーゲットのアンチパターンではありません。次の3つの条件をすべて満たす必要があります。

  1. UI サーフェスの状態保持者 — ViewModel、Decompose Component、feature model、または同等の UI 状態保持者です。リポジトリ、マネージャー、ユースケース、データソースではありません。
  2. ライフサイクルに結び付いたスコープ — viewModelScope、破棄時にキャンセルされる Component の coroutineScope、Composable の rememberCoroutineScope() です。AppScope、注入された長期間存続するスコープ、場当たり的な CoroutineScope(...) ではありません。
  3. 呼び出し元が実際に UI イベントであること — Composable のコールバック、キーイベントハンドラー、ライフサイクルフックです。状態保持者を介して呼び出す別のビジネスロジッククラスではありません。

下位のリポジトリ/ユースケース/データソース層では、引き続き suspend API を公開します。サスペンドしない処理からサスペンドする処理への変換が許されるのは、状態保持者の層だけです。

「状態保持者のように感じる」だけでは不十分です。問うべきなのは「UI がこれに直接バインドされているか」です。そうでなければ、この例外は適用されません。

4. 注入されていないスコープの保持

注入されたスコープを使わない、同じアンチパターンです。

// ❌ BAD — same problem, scope is constructed in-class instead of injected
class FooManager {
    private val scope = MainScope()
    private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
}

ライフサイクルはどこにも管理されず、永遠に存続します。suspend API に置き換えてください。

関数本体の中にインスタンス化がネストされている場合も同様です。fun foo() { CoroutineScope(...).launch { … } } は、手順を増やしただけの保持されたスコープです。呼び出すたびに、新たにキャンセル不能なスコープが漏れます。by lazy プロパティにまとめても、根本的な問題は解決しません(そもそもスコープが存在すべきではありません)。

5. DI にバインドされたシングルトン/初期化処理からの起動

見つけにくい特定のパターンがあります。DI にバインドされたクラス(@SingleIn(AppScope)、@Singleton、Initializer.initialize())が、コンストラクター/init ブロック/initialize() からコルーチンを起動するパターンです。起動された処理には次の問題があります。

  • 開始時刻が非決定的 — バインディングがグラフ内で実体化されたタイミングで開始されます。コールドスタート時の順序は把握できません。
  • 観測可能なライフサイクルがありません。 コードベース内の他の箇所から、実行中なのかクラッシュしたのかを確認できません。
  • stop()/再起動の経路がありません。 上流が不正な状態になっても、ループをキャンセルできません。
  • grep で見つけられる呼び出し元のコードがありません。 読み手は「誰が、いつ起動するのか」を見つけられません。

§1 では、スコープを呼び出し元のライフサイクルに結び付けるべきだとしています。DI にバインドされたこのパターンは、間接的にこれに違反します。スコープは注入されているかもしれませんが、起動処理が構築処理の中に隠れているため、同じ結果になり、さらに見つけにくくなります。

// ❌ BAD — singleton boots work as a side effect of being constructed
@SingleIn(AppScope::class)
@Inject
class TokenRefresher(
    @ForScope(AppScope::class) private val scope: CoroutineScope,
    private val auth: AuthService,
) {
    init {
        scope.launch {
            while (isActive) {
                delay(5.minutes)
                auth.refreshIfNeeded()
            }
        }
    }
}

// ❌ ALSO BAD — Initializer.initialize() that *launches*, not just registers
class TokenInvalidatorInitializer @Inject constructor(
    @ForScope(AppScope::class) private val scope: CoroutineScope,
    private val store: AuthStore,
    private val invalidator: TokenInvalidator,
) : Initializer {
    override fun initialize() {
        scope.launch { store.tokenChanges.collect { invalidator.invalidate() } }
    }
}

どちらも「アプリケーションスコープのシングルトン」のように見えますが、適用しないケースの例外は、init/initialize() から起動することを許可するものではありません。API がサスペンド可能な場合に、シングルトンがスコープを所有することを許可するものです。

まず、このバックグラウンドループクラスは本当に必要ですか?

バックグラウンドループクラスの多くは、誰も観察の向きを逆にしていないために存在します。優先度の高い順に、3 つの方法があります。

パターン 1 — コンシューマー側に反転する。 クラスは状態を監視し続け、変更に反応します。しかし、状態を変更する「誰か」がいます。サインアウト処理、プロフィールの切り替え、フラグ更新ハンドラーなどです。その変更箇所はすでにコルーチンコンテキストにあり、そこで直接処理するのが自然です。

// ✅ GOOD — no background loop, no scope, no class. The mutation site does the work.
class Authenticator(
    private val authStore: AuthStore,
    private val tokenInvalidator: TokenInvalidator,
) {
    suspend fun signOut() {
        authStore.clearTokens()
        tokenInvalidator.invalidate()   // direct call at the mutation site
    }
}

バックグラウンドループクラスは削除します。状態が変わる場所で処理を実行します。

適用できるケース:状態のコンシューマーに明確なライフサイクル(ユースケース、Authenticator、サービスハンドラーなど)があり、そこでインラインに反応処理を実行できる場合です。

パターン 2 — スケジュールされた処理。 本当に定期的な処理や遅延処理の場合です。WorkManager / BGTaskScheduler を使用します。エンキューは 1 回限りの処理とし、suspend 関数にして、起動時にすでに実行されているオーケストレーターから 1 度だけ呼び出します。

パターン 3 — 名前付きの明示的な起動箇所。 コンシューマーが、観測可能なライフサイクルを持たない同期 API である場合もあります(例:OpenTelemetry の Sampler.shouldSample(...)、AIDL スタブのファンアウト、ブロードキャストレシーバーブリッジ)。監視処理はコルーチン対応のどこかに置く必要がありますが、その場所は名前付きの明示的な呼び出し箇所でなければなりません。クラス自身の init に置いてはいけません。

// ✅ GOOD — work is named; an explicit call site owns the launch
@SingleIn(AppScope::class)
class OtelConfigurableSampler(...) : Sampler {
    @Volatile private var delegate: Sampler = ...

    suspend fun observeRate(featureFlags: FeatureFlags) {
        featureFlags.observe(OTEL_SAMPLING_RATE).collect { rate ->
            delegate = Sampler.traceIdRatioBased(rate.coerceIn(0.0, 1.0))
        }
    }

    override fun shouldSample(...) = delegate.shouldSample(...)
}

// wired explicitly at the OTel SDK init module:
applicationScope.launch { otelSampler.observeRate(featureFlags) }

適用できるケース:コンシューマーが、観測可能なライフサイクルを持たない同期 API で、あなたを呼び出す場合です。起動箇所を反転することはできませんが、名前付きの呼び出し箇所で明示する必要があります。

どのパターンが適切かを判断する

「コンシューマーのライフサイクルを観測できますか?」

  • はい。すでにコルーチンコンテキストにあります → パターン 1。サブスクリプションをコンシューマー側に移し、バックグラウンドループクラスを削除します。
  • 処理が定期的/遅延実行です → パターン 2。エンキューを suspend 関数にし、1 度だけ呼び出します。
  • いいえ。観測可能なライフサイクルを持たない同期 API です → パターン 3。init ではなく、明示的な起動箇所を設けます。

「すべてを起動する Bootable インターフェースを作りたい」など、4 つ目の答えが当てはまりそうな場合、それは抽象化をもう 1 層加えただけで、同じアンチパターンです。起動箇所を見えるようにすることが重要です。インターフェースによる自動検出は、その目的に反します。

初期化処理も問題ありません — 登録だけを行うなら

initialize() がリスナーやフックを登録する場合、Initializer パターンは適切です。問題は、initialize() がコルーチンを起動する場合です。

// ✅ GOOD Initializer — registers a contributor, doesn't launch
class FavouritesContributorInitializer @Inject constructor(
    private val registry: ContributorRegistry,
    private val favouritesContributor: FavouritesContributor,
) : Initializer {
    override fun initialize() {
        registry.register(favouritesContributor)
    }
}

Initializer.initialize() でコルーチンを launch してはいけません。 該当する場合は、パターン 1/2/3 の候補です。

レビュー時の診断
  • 開始タイミングはどこで定義されていますか?「DI がインスタンス化する場所ならどこでも」なら問題です。
  • 処理が実行中かどうかを誰が確認できますか?「誰もいない」なら問題です。
  • 誰が処理を停止または再起動できますか?「誰もいない」なら問題です。
  • 読者は起動箇所を grep で検索できますか?できないなら問題です。

答えが「コンシューマー/オーケストレーター/名前付きの呼び出し箇所」なら問題ありません。

6. CancellationExceptionを握りつぶす

CancellationExceptionに一致するcatch節をsuspend呼び出しの周囲に置き、それを再スローしない場合、直接一致する場合も、Exception/Throwableを介して一致する場合も、通常はキャンセルが黙って成功したかのように扱われます。親コルーチンは子が完了したと思い込みますが、子は実行を続けるか(またはその副作用が続き)、キャンセルの契約が破られます。

§1のスコープ保存バグと同じ失敗の形ですが、反対側から見たものです。§1では作業が呼び出し元のライフサイクルから「隠され」ますが、こちらではキャンセルが作業から「隠され」ます。

// ❌ BAD — catches CancellationException, never rethrows
suspend fun fetch() {
    try {
        api.load()
    } catch (e: Exception) {           // matches CancellationException too
        logger.warn("load failed", e)
    }
}

// ❌ ALSO BAD — runCatching has the same problem
suspend fun fetch() {
    runCatching { api.load() }
        .onFailure { logger.warn("load failed", it) }
}

許容される形は次のとおりです。

// ✅ Separate catch first
try { api.load() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { logger.warn("load failed", e) }

// ✅ Conditional rethrow inside the broad catch
try { api.load() }
catch (e: Exception) {
    if (e is CancellationException) throw e
    logger.warn("load failed", e)
}

// ✅ ensureActive() — good when the catch handles ordinary failures and you only need
// to rethrow if the current coroutine is cancelled
try { api.load() }
catch (e: Exception) {
    currentCoroutineContext().ensureActive()
    logger.warn("load failed", e)
}

// ✅ runCatching with explicit guard
runCatching { api.load() }
    .onFailure {
        if (it is CancellationException) throw it
        logger.warn("load failed", it)
    }

// ✅ runCatching terminated with getOrThrow (cancellation flows back out)
runCatching { api.load() }.getOrThrow()

判定のポイントは「囲まれた関数がsuspendとして宣言されているか」ではなく、「try内にsuspend呼び出しがあるか」です。これは、suspend fun、launch { … }ラムダ、Flowのcollect { … }など、あらゆるサスペンド可能な本体の内部で該当します。

一般的な例外は、意図的にローカルなタイムアウトです。独自のwithTimeoutから発生したTimeoutCancellationExceptionを捕捉して、ドメインの結果に変換するのは正しい場合があります。そのcatchは対象を絞り、タイムアウトの近くに置いてください。任意のキャンセルを握りつぶしてよいという許可として使ってはいけません。

キャンセルに該当しないサブタイプ(IOExceptionや独自の例外型)を捕捉するのは問題ありません。これらはCancellationExceptionを継承していません。

7. runBlocking

runBlockingはラムダが完了するまで現在のスレッドを停止します。サスペンド可能なアプリケーションの処理経路やライフサイクルスコープ内で使うのは誤りです。非同期であるはずのスレッドがブロックされ、構造化並行性が破られ、上流からのキャンセルも効果を失います。これは「呼び出される側が呼び出し元に代わって構造上の判断を下す」というアンチパターンの、最も直接的な形です。

// ❌ BAD — bridging to suspend by blocking the calling thread
fun saveUser(user: User) {
    runBlocking { repository.save(user) }
}

状況に応じた修正方法は次の3つです。

サスペンド可能なアプリケーションコード — 関数をsuspendにします。

// ✅ GOOD
suspend fun saveUser(user: User) = repository.save(user)

直近の呼び出し元もサスペンドできない場合(サスペンドしないUIコールバックやBroadcastReceiverフックなど)は、境界で既存のライフサイクルに紐づいたスコープを使ってください。§3のUI ↔ 状態保持クラスの例外を参照してください。修正するのは境界であり、saveUserの内部ではありません。

正当なブロッキング境界も存在します。CLIツールのmain、同期的に戻る必要があるJava相互運用API、サスペンド可能な代替手段がないフレームワークのコールバック、移行用の橋渡しコードなどです。runBlockingはこうした外側の境界に置き、本体を短く保ち、直ちにサスペンド可能なコードを呼び出してください。

テスト — runTestを使います。

// ❌ BAD — real time, slow tests, no virtual delay
@Test fun loadsUser() = runBlocking {
    assertThat(repository.load().name).isEqualTo("Alice")
}

// ✅ GOOD
@Test fun loadsUser() = runTest {
    assertThat(repository.load().name).isEqualTo("Alice")
}

runTestでは仮想時間(delay()が即座に戻ります)、TestDispatcherとの統合、適切なコルーチンのクリーンアップを利用できます。テストで実時間を使うrunBlockingを使うと、テストが遅くなり、不安定になります。

ContentProviderの例外 — AndroidのContentProviderメソッド(query、insert、update、delete、onCreate、call)は、プロセスの外部からは同期的に呼び出されます。これらをサスペンドする方法はありません。ContentProviderサブクラスのメンバー関数内(直接・間接を問わず。コンパニオンオブジェクトは除く)では、runBlockingが避けられない橋渡しになります。本体をできるだけ短くし、直ちにサスペンド可能なコードを呼び出してください。

// ✅ Acceptable in ContentProvider members only
class MyProvider : ContentProvider() {
    override fun query(...): Cursor? = runBlocking { dao.query(...) }
}

この例外が適用されるのは、android.content.ContentProviderのサブクラスのみです。「ContentProviderのようなもの」という理由では適用されません。また、ContentProviderのコンパニオンオブジェクト内にあるrunBlockingも通常どおり違反です。このヘルパーはフレームワークの同期的なインターフェースには含まれません。

クイックリファレンス

症状アンチパターン修正
クラスに private val scope: CoroutineScope がある呼び出される側にスコープを保持する削除します。公開 API を suspend にします。
init { scope.launch { ... } }コンストラクターでの起動suspend fun init() / login() に移します
リポジトリ/マネージャー/ユースケースの fun foo() { scope.launch { ... } }UI 以外のクラスからのファイア・アンド・フォーゲットsuspend fun foo() にし、UI の状態ホルダーにスコープを選ばせます
UI から呼び出される状態ホルダーの fun onClick() { viewModelScope.launch { ... } }UI ↔ 状態ホルダーの境界 — 問題ありませんそのままにします(§3 の例外を参照)
private val scope = MainScope()内部で構築して保持するスコープ同様に削除し、API を suspend にします
@SingleIn(AppScope) class X(scope) { init { scope.launch { … } } }DI に結び付いた不透明な起動(§5)suspend fun run() を公開し、起動オーケストレーターから起動します
class Y : Initializer { override fun initialize() { scope.launch { … } } }登録ではなく起動を行うイニシャライザー(§5)同様に suspend fun run() にし、オーケストレーターにライフサイクルを管理させます
再スローしない try { suspendCall() } catch (e: Exception|Throwable|CancellationException) { … }キャンセルの握りつぶし(§6)catch (e: CancellationException) { throw e } を優先します。意図に合う場合に限り ensureActive() を使います
キャンセルガードのない runCatching { suspendCall() }.onFailure { … }上記と同様のパターン(§6)if (it is CancellationException) throw it を追加するか、.getOrThrow() で終了します
サスペンドに対応可能なアプリコード内の runBlocking { … }スレッドをブロックするブリッジ(§7)呼び出し元を suspend にするか、境界でライフサイクルスコープを使います
テスト内の runBlocking { … }同様に、リアルタイムのブリッジ(§7)runTest { … } を使います
ContentProvider.query/insert/… のメンバー内の runBlocking { … }例外(§7)許容されます。本文は最小限に保ちます

リファクタリングの指針

既存の違反箇所を取り除くには:

  1. 末端から始めます。 UI から最も遠いクラスを選びます。通常はリポジトリまたはデータソースです。公開インターフェースを最も簡単に変換できるはずです。
  2. 公開関数を一つずつ suspend に変換します。 コンパイラーがすべての呼び出し元を示します。
  3. 呼び出し元ごとに、スコープを慎重に選びます。 viewModelScope、lifecycleScope、coroutineScope { }、または明示的なジョブを使います。これまで欠けていたのが、この選択です。
  4. 使われなくなったら、CoroutineScope のコンストラクターパラメーターを削除します。 インジェクションバインディングも削除します。

一度の MR ですべてのクラスを修正しようとしないでください。アンチパターンの解消は段階的に進めます。

適用しない場合

  • UI イベントを受け取る UI 状態ホルダー。 fun onClick(...) { viewModelScope.launch { ... } } を持つ ViewModel/Component/機能モデルは正しいパターンです。これはフレームワークが必要とする境界です。§3 の例外を参照してください。
  • 明示的なキャンセルとエラーポリシーを持つライフサイクルオーナー。 アクター/サービス、アプリのインフラストラクチャ、またはアプリケーションスコープのシングルトンは、明確な close/cancel/再起動の動作を公開する場合や、それ以外の方法でアプリケーションのライフサイクルに直接対応する場合に、スコープを所有できます。場当たり的に作成するのではなく、Application.applicationScope を明示的にインジェクトしてください。これは init / initialize() から起動してよいという意味ではありません — §5 を参照してください。
  • すでにサスペンド可能な API には、この作業は不要です。
  • テストでは、意図的にアンビエントスコープとして TestScope を使うことがあります。これは仮想時間を明示的に制御する別のパターンです。

レビュー時に見られる危険信号

次のように考えているなら、アンチパターンが再発しています。

考え実際のところ
「スコープに CoroutineExceptionHandler を追加するだけでいい」問題はエラー処理ではありません。そもそも、そのスコープが存在すべきではないのです。
「利用側が来たときにデータが準備できているように、init から起動する必要がある」準備ができていない状態を利用側が読み取ることが問題です。フェーズ分けを使用してください。
「呼び出し元は suspend を扱いたくない」それなら、呼び出し元が自身のスコープで結果を待たない呼び出しを選べばよいのです。呼び出し元に代わって決めないでください。
「結果を待たない小さな呼び出しにすぎない」キャンセルが通知されないため、結果を待たない呼び出しはすべて、気づかれないまま失敗する可能性があります。
「例外を捕捉してログに記録したので問題ない」その捕捉処理は CancellationException を再スローしましたか? そうでない場合、コルーチンのキャンセルが気づかれないまま解除されます。(§6)
「重要でない処理の中の runBlocking は一度だけだから問題ない」どの runBlocking も、呼び出し元には非同期で処理する選択肢がないと断定します。選択肢があるなら、使うべきプリミティブではありません。(§7)
「テストでは runBlocking を使うほうが簡単」テストは実時間で実行されるため、delay を早送りできず、TestDispatcher のセマンティクスも失われます。runTest を使用してください。(§7)

関連項目

コメント