Lifecycle and cancellation
Latest stable Based on Bluetape4k release 1.11.0
Where a coroutine ends matters more than where it starts. Moving request work into a component scope loses caller cancellation; forgetting to close a component-owned dispatcher leaks threads.
The two ownership models
Section titled “The two ownership models”| Owner | Default choice | Termination boundary |
|---|---|---|
| HTTP request, message handler, CLI command | caller scope and coroutineScope | return or caller cancellation |
| component that survives across calls | a CloseableCoroutineScope implementation | component close() or framework shutdown |
A request that only combines a few calls does not need a new scope.
suspend fun loadDashboard(id: String): Dashboard = coroutineScope { val profile = async { profileClient.load(id) } val notices = async { noticeClient.load(id) } Dashboard(profile.await(), notices.await())}The children are cancelled with the caller and none can outlive the function.
The closeable-scope contract
Section titled “The closeable-scope contract”CloseableCoroutineScope guards _closed and _cancelled with atomic compare-and-set operations.
- The first
close()changesscopeClosedto true. clearJobs()callscancelChildren(cause)first.- It then cancels the scope context itself.
- Later close or clear calls cannot reopen or repeat that state transition.
DefaultCoroutineScope owns Dispatchers.Default + SupervisorJob(). Supervision isolates sibling failures during normal operation, while owner close still cancels every child.
class ThumbnailWorker : AutoCloseable { private val scope = DefaultCoroutineScope()
fun submit(imageId: String): Job = scope.launch { thumbnailService.generate(imageId) }
override fun close() = scope.close()}Owning a dispatcher
Section titled “Owning a dispatcher”ThreadPoolCoroutineScope(poolSize, name) validates a positive pool size and combines a fixed executor dispatcher with a SupervisorJob. Cancelling coroutines is not enough; the executor must also be closed.
class BlockingAdapter : AutoCloseable { private val scope = ThreadPoolCoroutineScope(poolSize = 4, name = "legacy-io")
suspend fun <T> call(block: () -> T): T = withContext(scope.coroutineContext) { block() }
override fun close() = scope.close()}Its override calls the parent close and then closes the dispatcher behind a second CAS guard. A non-positive pool size fails at construction.
Preserve cancellation
Section titled “Preserve cancellation”Cancellation is a structured control signal, not an ordinary application failure.
try { remoteClient.load()} catch (e: CancellationException) { throw e} catch (e: Exception) { fallback(e)}A cause passed to clearJobs(CancellationException("shutdown")) reaches children at their next suspension point. The representative test also verifies that the parent Job becomes inactive.
Testing owned resources
Section titled “Testing owned resources”Fixtures that create dispatchers must close them on every path.
private val scopeLazy = lazy { ThreadPoolCoroutineScope(poolSize = 2) }
@AfterEachfun closeScope() { if (scopeLazy.isInitialized()) scopeLazy.value.close()}use {} is equally valid. The invariant is that success, failure, and assertion failure all cross the same close boundary.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Boundary to inspect | Action |
|---|---|---|
| work continues after a request | child escaped into application/component scope | launch inside caller scope |
| threads remain after shutdown | owner of fixed or virtual dispatcher | connect owner close to shutdown |
| one child failure cancels all work | Job versus SupervisorJob policy | supervise only independent results |
| remote I/O survives a timeout | client cancellation bridge | add client deadline and idempotency rules |
Source and verification
Section titled “Source and verification”CloseableCoroutineScope.ktDefaultCoroutineScope.ktThreadPoolCoroutineScope.ktAbstractCoroutineScopeTest.kt
Next: define winner and loser-cleanup semantics in Deferred coordination.