Skip to content
Bluetape4k docs1.11

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.

Creation, child cancellation, and dispatcher shutdown order for CloseableCoroutineScope

OwnerDefault choiceTermination boundary
HTTP request, message handler, CLI commandcaller scope and coroutineScopereturn or caller cancellation
component that survives across callsa CloseableCoroutineScope implementationcomponent 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.

CloseableCoroutineScope guards _closed and _cancelled with atomic compare-and-set operations.

  1. The first close() changes scopeClosed to true.
  2. clearJobs() calls cancelChildren(cause) first.
  3. It then cancels the scope context itself.
  4. 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()
}

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.

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.

Fixtures that create dispatchers must close them on every path.

private val scopeLazy = lazy { ThreadPoolCoroutineScope(poolSize = 2) }
@AfterEach
fun 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.

SymptomBoundary to inspectAction
work continues after a requestchild escaped into application/component scopelaunch inside caller scope
threads remain after shutdownowner of fixed or virtual dispatcherconnect owner close to shutdown
one child failure cancels all workJob versus SupervisorJob policysupervise only independent results
remote I/O survives a timeoutclient cancellation bridgeadd client deadline and idempotency rules

Next: define winner and loser-cleanup semantics in Deferred coordination.