This is probably a stupid question, but is there a...
# coroutines
r
This is probably a stupid question, but is there anyway to wrap up this:
Copy code
sealed interface FooResult
data object FooCancelled : FooResult
data class FooSuccess : FooResult

suspend fun foo(): FooResult = TODO()

val deferred = async { foo() }
deferred.cancel()
val result = deferred.await()
so that the last call will never throw
CancellationException
, instead returning
FooCancelled
? Without a try / catch (or equivalent) around
deferred.await()
?
y
You could maybe use a CompletableDeferred and a
launch
, and simply cancel the launched coroutine and complete with
FooCancelled
r
This still throws
JobCancellationException
on the call to
deferred.await()
. Perhaps it's just the nature of the beast, and I should stop fretting about it... I do hate exceptions though. My attempt:
Copy code
sealed interface FooResult
data object FooCancelled : FooResult
data class FooSuccess(val data: String) : FooResult

suspend fun doFoo(): FooResult {
  delay(2000.milliseconds)
  return FooSuccess("foo")
}

suspend fun foo(): Deferred<FooResult> = withContext(<http://Dispatchers.IO|Dispatchers.IO>) {
  val result = CompletableDeferred<FooResult>()
  try {
    launch(result) {
      val work = doFoo()
      result.complete(work)
    }
  } catch (_: CancellationException) {
    result.complete(FooCancelled)
  }
  result
}

fun main() {
  val result = runBlocking {
    val deferred = foo()
    deferred.cancel()
    deferred.await()
  }
  assert(result == FooCancelled) { "$result should be $FooCancelled" }
}
y
I think move the try-catch inside the
launch
, and make sure to rethrow the exception
r
That didn't work for me, but creating my own
UncancellableDeferred
did. Is this bad practice?
Copy code
sealed interface FooResult
data object FooCancelled : FooResult
data class FooSuccess(val data: String) : FooResult

suspend fun foo(): Deferred<FooResult> = coroutineScope {
  val result = CompletableDeferred<FooResult>()
  launch(result) {
    val work = doFoo()
    result.complete(work)
  }
  UncancellableDeferred(FooCancelled, result)
}

private suspend fun doFoo(): FooResult {
  delay(20000.milliseconds)
  return FooSuccess("foo")
}

fun main() {
  val result = runBlocking {
    val deferred = foo()
    deferred.cancel()
    deferred.await()
  }
  assert(result == FooCancelled) { "$result should be $FooCancelled" }
}

@OptIn(InternalForInheritanceCoroutinesApi::class)
class UncancellableDeferred<T>(
  private val ifCancelled: T,
  private val delegate: Deferred<T>,
) : Deferred<T> by delegate {
  override suspend fun await(): T = try {
    delegate.await()
  } catch (_: CancellationException) {
    ifCancelled
  }
}
Well d'oh, of course it's bad practice, the
@OptIn(InternalForInheritanceCoroutinesApi::class)
literally tells me that 😳
y
Just remove the inheritance. It's unlikely you need it
r
True...
OK, so this works:
Copy code
sealed interface FooResult
data object FooCancelled : FooResult
data class FooSuccess(val data: String) : FooResult

suspend fun foo(): SafeDeferred<FooResult> = coroutineScope {
  val result = CompletableDeferred<FooResult>()
  launch(result) {
    val work = doFoo()
    result.complete(work)
  }
  SafeDeferred(FooCancelled, result)
}

private suspend fun doFoo(): FooResult {
  delay(3000.milliseconds)
  return FooSuccess("foo")
}

fun main() {
  val result = runBlocking {
    val deferred = foo()
    deferred.cancel()
    deferred.await()
  }
  assert(result == FooCancelled) { "$result should be $FooCancelled" }
}

class SafeDeferred<T>(
  private val ifCancelled: T,
  private val delegate: Deferred<T>,
) {
  suspend fun await(): T = try {
    delegate.await()
  } catch (_: CancellationException) {
    ifCancelled
  }

  fun cancel(): Unit = delegate.cancel()
}
However, if I try and rewrite
suspend fun foo()
as so:
Copy code
suspend fun foo(): SafeDeferred<FooResult> = coroutineScope {
  val deferred = async { doFoo() }
  SafeDeferred(FooCancelled, deferred)
}
the cancellation doesn't get propagated to
delay
, so I do get
FooCancelled
back but only after waiting 3 seconds. So clearly I'm still not getting how cancellation is propagated.
y
coroutineScope
waits for its children to complete, so I don't think that's what you want here...
z
Why do you want to not throw a CE? Generally that is bad and will break coroutine cancellation which will probably be super fun to debug later.
r
I'm really new to coroutines, and I really loathe APIs that throw exceptions, but I want to offer APIs that allow cancellation, so I'm trying to work out what that would look like.
Because I'm learning I'm pretty hazy about a lot of aspects of cancellation propagation, and what an API that expects to be called in a coroutine context should look like.
z
Suspend functions that are cancellable need to throw CancellationExceptions. Anything else will be extremely unidiomatic and break/surprise your users. Feel free to convert every other kind of exception in some kind of result type, but if you’re building a coroutines api you need to throw CEs. Be careful about how you do that though, I highly recommend reading this article.
r
Thanks, will do
y
I think try just doing:
Copy code
fun CoroutineScope.foo() = safeDeferred(FooCancelled, async { doFoo() })
And that should do the trick. Maybe throw a simple
ensureActive()
in there so that you don't accidentally swallow a cancellation
l
Don't hate exceptions. Structured concurrency (cancellation and failure propagation) work with them, and only them.