Kotlin Flow timeout operator and Java ExecutorService integration: does timeout cancel upstream collector or only drop emissions?
0 reputation · 17 Mar 2023, 03:23 UTC
0 reputation · 17 Mar 2023, 03:23 UTC
When a Kotlin Flow is collected on a dispatcher created with Dispatchers.from(ExecutorService), applying the timeout operator raises a design question about what happens to the upstream collector when the timeout expires. The timeout operator may either cancel the upstream collector, triggering its onCompletion and releasing resources tied to the ExecutorService, or it may simply stop emitting further values while leaving the collector active. This distinction matters because the ExecutorService may continue to run submitted tasks unless the underlying thread is interrupted or the dispatcher is shut down.
Given the differing behavior of timeout across StateFlow and channel‑based flows, and the need to ensure that Java‑backed threads do not linger after a timeout, it is unclear whether the current implementation propagates cancellation upstream or merely drops emissions.
Does the timeout operator cancel the upstream collector when used with an ExecutorService‑backed dispatcher? If not, what additional steps are required to guarantee that the ExecutorService releases its resources after a timeout?
31775 reputation · 17 Mar 2023, 06:35 UTC
Yes — timeout() cancels the upstream collector; it does not merely drop emissions. But it does not shut down the underlying ExecutorService. Those are two separate lifecycles, and conflating them is what causes lingering Java threads.
The timeout() operator is implemented on top of withTimeoutOrNull: it runs the upstream collection inside a coroutine with a deadline. When the deadline expires, that collecting coroutine is cancelled via normal coroutine cancellation — a TimeoutCancellationException (a subtype of CancellationException) propagates upstream through the flow chain. Consequently:
onCompletion runs with the cancellation cause, and finally blocks in upstream operators execute.callbackFlow, handles closed in finally) are released.StateFlow, the cancelled collector simply stops receiving updates; the StateFlow and its other collectors are unaffected.So the "only drops emissions" model is wrong — that describes filtering/backpressure operators like sample or conflate, not timeout. Timeout terminates collection.
Cancellation stops the coroutine, not the thread pool. A dispatcher created via Dispatchers.from(executor) or asCoroutineDispatcher() borrows the executor; the pool's threads stay alive until you shut the pool down. Additionally, cancellation is cooperative: it is delivered at suspension points. If upstream code runs a blocking Java call that ignores interruption, the coroutine is logically cancelled but the executor thread stays occupied until that call returns.
Own the executor lifecycle explicitly around the timed collection:
val executor = Executors.newFixedThreadPool(4)
try {
executor.asCoroutineDispatcher().use { dispatcher ->
withContext(dispatcher) {
flow.timeout(5.seconds).collect { emit -> handle(emit) }
}
}
} finally {
executor.shutdown() // or shutdownNow() to interrupt running tasks
executor.awaitTermination(2, TimeUnit.SECONDS)
}Notes:
shutdown() lets in-flight tasks finish; shutdownNow() interrupts them. Choose based on whether your blocking work responds to interruption.Dispatchers.from grants no ownership.ensureActive()/isActive in loops so cancellation is prompt.timeout on the executor dispatcher and assert onCompletion fires with a TimeoutCancellationException cause.ThreadPoolExecutor.getActiveCount() or a thread dump to confirm no task lingers.Thread.currentThread().isInterrupted inside blocking upstream work to see whether interruption reaches the thread.These semantics are stable across kotlinx.coroutines versions, though the internal implementation of timeout() varies — check the KDoc for your specific version if you depend on internals.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.