kotlinx.coroutines icon indicating copy to clipboard operation
kotlinx.coroutines copied to clipboard

Feature Request: invokeOnCompletion handler for suspendCancellableCoroutine

Open jschools opened this issue 3 years ago • 14 comments

It is inconvenient to use suspendCancellableCoroutine to wrap an API that requires a callback to be unregistered after receiving the call. Examples include "event bus" or "broadcast" APIs which do not unregister a callback automatically and will leak the callback.

We have invokeOnCancellation to handle situations where the coroutine is cancelled, although there is no handler for when the continuation is resumed successfully. This potentially could be done in the callback itself prior to calling cont.resume(), but it is not possible to get a reference to the callback if it is a lambda. It may also duplicate the code in the invokeOnCancellation block.

For example:

suspendCancellableCoroutine<Int> { cont ->
  val callback: (Int) -> Unit = {
    someApi.unregisterCallback(???) // no way to get reference to a lambda - cannot use `this` or `callback`
    cont.resume(it)
  }
  
  someApi.registerCallback(callback)
  
  cont.invokeOnCancellation {
    // not called after successful resume!
    someApi.unregisterCallback(callback)
  }
}

I propose adding invokeOnCompletion to CancelableContinuation which could be used to handle both cancellation and successful resume, similar to Job.invokeOnCompletion.

If there is a simpler way around this that I am missing, please do let me know.

jschools avatar Dec 06 '21 20:12 jschools

Note that you could define your callback as an object instead of a lambda to get access to this:

    val callback = object : (Int) -> Unit {
        override fun invoke(value: Int) {
            someApi.unregisterCallback(this)
            cont.resume(value)
        }
    }

But the argument about repeating code still holds though.

joffrey-bion avatar Dec 06 '21 23:12 joffrey-bion

Examples include "event bus" or "broadcast" APIs which do not unregister a callback automatically and will leak the callback.

@jschools Thinking about this, why would you wrap those APIs into suspend functions, though? Looks like a callbackFlow use case to me instead.

If you just want the first event from those APIs, then simply call first() on the returned flow.

joffrey-bion avatar Dec 07 '21 11:12 joffrey-bion

@joffrey-bion I was thinking it would be convenient for suspendCancelableCoroutine to support a kind of "try/finally" option, in case an API needed to be destroyed or torn down after a successful resume. There are plenty of clumsy SDKs out there...

I agree callbackFlow solves all of these cases. Wouldn't that be an argument that it could replace suspendCancelableCoroutine altogether? I'm happy to close this issue if you think it's not a large enough concern 😀

jschools avatar Dec 07 '21 18:12 jschools

I agree callbackFlow solves all of these cases. Wouldn't that be an argument that it could replace suspendCancelableCoroutine altogether?

They solve different problems, though. callbackFlow is meant to wrap APIs that call a callback repeatedly and expose results as a Flow, while suspendCancellableCoroutine is for single-shot callbacks and exposes this call as a suspend function.

There are plenty of clumsy SDKs out there...

Yeah, if some SDKs take a single-shot callback that needs to be explicitly unregistered, it's pretty crappy indeed. But when this happens there are options, like the above.

I'm happy to close this issue if you think it's not a large enough concern

I'm not opposed to this addition, though, don't get me wrong. Also I am by no means of any authority in this repository 😄 I'll let the maintainers decide for themselves

joffrey-bion avatar Dec 07 '21 20:12 joffrey-bion

@joffrey-bion I was thinking it would be convenient for suspendCancelableCoroutine to support a kind of "try/finally" option, in case an API needed to be destroyed or torn down after a successful resume. There are plenty of clumsy SDKs out there...

Actually, you can use try/finally with suspendCancellableCoroutine, you can surround the call to suspendCancellableCoroutine itself with the try block. Just make sure you don't risk crashing because resume has been called twice while the coroutine was completing, but didn't reach the finally block yet to unregister the callback because of the dispatch being queued.

LouisCAD avatar Dec 14 '21 08:12 LouisCAD

@LouisCAD I think it would not be very convenient to do with a try/finally around the suspendCancellableCoroutine, because the finally likely needs a reference to the callback to unregister it, and the callback needs a reference to the continuation provided by suspendCancellableCoroutine. So it becomes a chicken-and-egg problem here 😄 and this would require some kind of lazy mechanism or lateinit var, which I guess is even uglier than just repeating the cleanup in both places

joffrey-bion avatar Dec 14 '21 09:12 joffrey-bion