rakeeb
04/16/2025, 9:35 PMclass SuspendedInvocationTimer(
val onTimerExpired: suspend () -> Unit,
) {
suspend fun start(duration: Duration) = coroutineScope {
delay(duration)
if (isActive) {
onTimerExpired()
}
}
}
Even when the parent coroutine scope is cancelled the onTimerExpired
lambda is still invoked. I've tried a few variations like:
suspend fun start(duration: Duration) = coroutineScope {
val job = launch { delay(duration) }
job.join()
val isCancelling = !job.isActive && !job.isCompleted && job.isCancelled
val isCancelled = !job.isActive && job.isCompleted && job.isCancelled
if (!isCancelling && !isCancelled) {
onTimerExpired()
}
}
or checking to see the parent coroutine scope as well, none of them seem to work. The onTimerExpired
lambda always gets invoked even if the timer got cancelled.
What is the fault in my approach here?Zach Klippenstein (he/him) [MOD]
04/16/2025, 10:10 PMZach Klippenstein (he/him) [MOD]
04/16/2025, 10:11 PMdelay()
from wherever you're using it?Zach Klippenstein (he/him) [MOD]
04/16/2025, 10:12 PMcoroutineScope {}
, you're not launching any coroutinesZach Klippenstein (he/him) [MOD]
04/16/2025, 10:13 PMNonCancellable
job? Cancelling the coroutine calling start
during the delay should cause delay
to throw an exception, so it sounds like that's not happeningrakeeb
04/17/2025, 12:45 AMdelay
in place.
i didn't need the coroutineScope
in the first iteration, apologies the first snippet was a copy paste error, I've been actively modifying this implementation. the current iteration of this timer is as follows:
class SuspendedInvocationTimer(
private val onTimerExpired: suspend () -> Unit,
) {
private val timerJob: AtomicReference<Job?> = AtomicReference()
suspend fun start(duration: Duration) {
cancel()
timerJob.getAndSet(createCoroutineJob(duration))
}
suspend fun cancel() {
val job = timerJob.get() ?: return
job.cancelAndJoin()
timerJob.getAndSet(null)
}
private suspend fun createCoroutineJob(duration: Duration) = coroutineScope {
launch {
delay(duration)
timerJob.getAndSet(null)
if (isActive) onTimerExpired()
}
}
}
The cancellation is explicitly made on the timer class itself.rakeeb
04/17/2025, 4:43 PMcoroutineScope
was the issue, I interpreted its documentation as a way to group related coroutines together. I found out that it doesn't necessarily allow you to cancel individual coroutines inside it.
fun CoroutineScope.delayedTaskExt(
duration: Duration,
task: suspend () -> Unit
): Job = launch {
delay(duration)
task()
}
the following function me to achieve my desired behavior.Zach Klippenstein (he/him) [MOD]
04/17/2025, 6:00 PMAtomicReference
. The whole point of it is to use getAndSet
to… get and set, atomically, but you're doing them as separate steps in cancel
.Zach Klippenstein (he/him) [MOD]
04/17/2025, 6:00 PMgetAndSet
if you're only setting – just use set
Zach Klippenstein (he/him) [MOD]
04/17/2025, 6:11 PMSuspendedInvocationTimer
.
Firstly, since createCoroutineJob
won't return until the launched job finishes, timerJob
will only ever be set after the onTimerExpired
callback returns. I.e. it never tracks the "active" job, only already-finished jobs.
Now, if you fixed that and made it launch the job and return it immediately so it could be stored in timerJob
, you'd still have a race condition and unnecessary delays in your code:
1. Coroutine A calls start
while another job is active. This calls cancel
which gets the job and then cancels it.
2. While cancelAndJoin
is running, Coroutine B calls cancel
, which gets the same job and joins on it. So now both A and B are waiting on the same job unnecessarily. This is also kind of surprising timing, since the cancel
call happens after the start
call but is operating on a previous job.
3. Coroutine A resumes: cancel
clears timerJob
, returns, and start
launches the new job and sets it in timerJob
.
4. Coroutine B resumes: cancel
clears timerJob
, which it thinks is still the old job it started canceling, but has already been set to the new job from Coroutine A's start
call. Now it looks like the job was cancelled, because timerJob
is null, but it's actually still running, and a subsequent cancel
call will no-op.Zach Klippenstein (he/him) [MOD]
04/17/2025, 6:28 PMAtomicReference
for this sort of thing would be to do something like:
suspend fun start(duration: Duration) {
val newJob = createCoroutineJob(duration)
replaceJob(newJob)
}
suspend fun cancel() {
replaceJob(null)
}
private fun replaceJob(newJob: Job?) {
timerJob.getAndSet(newJob)?.cancel()
}
rakeeb
04/18/2025, 12:07 PMrakeeb
04/28/2025, 11:41 PMAtomicReference
the way they are meant to be. a bit embarrassed with my ignorant first implementation. thanks again, i was able to come up with the following solution:
class CancelPreviousDeferredInvocationTimer(
private val coroutineScope: CoroutineScope,
private val onTimerExpired: suspend () -> Unit,
) {
private val timerJob: AtomicReference<Job?> = AtomicReference()
suspend fun start(duration: Duration) {
val newJob = createJob(duration)
newJob.invokeOnCompletion { timerJob.compareAndSet(newJob, null) }
while (true) {
val currentJob = timerJob.get()
if (timerJob.compareAndSet(currentJob, newJob)) {
currentJob?.cancelAndJoin()
break
}
}
}
suspend fun cancel() {
while(true) {
val currentJob = timerJob.get() ?: break
if (timerJob.compareAndSet(currentJob, null)) {
currentJob.cancelAndJoin()
break
}
}
}
fun isActive(): Boolean = timerJob.get()?.isActive ?: false
private fun createJob(duration: Duration): Job = coroutineScope.launch {
delay(duration)
onTimerExpired()
}
}
thanks again man! appreciated your help