Kotlin/kotlinx.coroutines

Documentation needs adjustments: states async cannot cause uncaught exceptions but async can if fails before it is awaited.

Opened this issue · 6 comments

Documentation of CoroutineExceptionHandler states:

An optional element in the coroutine context to handle uncaught exceptions.

...

A coroutine that was created using [async][CoroutineScope.async] always catches all its exceptions and represents them in the resulting [Deferred] object, so it cannot result in uncaught exceptions. - reference

The above states that: async cannot create uncaught exceptions.

However: IF async throws an exception BEFORE we called await on the deferred object, THEN uncaught exception happens BUT CoroutineExceptionHandler does not appear to be invoked.

Code to reproduce

Code self contained

package com.glassthought.sandbox

import kotlinx.coroutines.*
import java.time.Instant
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds


fun main(): Unit {
  val coroutineExceptionHandler = CoroutineExceptionHandler { _, ex ->
    runBlocking(CoroutineName("CoroutineExceptionHandler")) {
      printlnWithTime("Caught exception in CoroutineExceptionHandler: ${ex::class.simpleName} with message=[${ex.message}].")
    }
  }

  val mainJob = Job()
  val scope = CoroutineScope(mainJob + coroutineExceptionHandler)

  scope.launch(CoroutineName("SpawnedFromMain")) {
    actionWithMsg("foo", { foo(scope) })
  }

  runBlocking {
    actionWithMsg("mainJob.join()", { mainJob.join() })
  }
}

private suspend fun foo(scope: CoroutineScope) {
  val deferred = scope.async(CoroutineName("async-1")) {
    actionWithMsg("throw-exception", {
      throw MyRuntimeException.create("exception-from-async-before-it-got-to-await")
    }, delayDuration = 500.milliseconds)
  }

  printlnWithTime("Just launched co-routines.")
  actionWithMsg(
    "deferred.await()",
    {
      deferred.await()
    },
    delayDuration = 1.seconds
  )
}

suspend fun <T> actionWithMsg(
  actionName: String,
  action: suspend () -> T,
  delayDuration: Duration = Duration.ZERO
): T {
  val hasDelay = delayDuration > Duration.ZERO
  if (hasDelay) {
    printlnWithTime("action=[$actionName] is being delayed for=[${delayDuration.inWholeMilliseconds} ms] before starting.")

    delay(delayDuration)
  }
  printlnWithTime("action=[$actionName] is starting.")

  var result: T


  try {
    result = action()
  } catch (exc: Exception) {
    // We are going to rethrow all exceptions, so CancellationException will also be rethrown.
    // Therefore, we respect: [Cooperative Cancellation](http://www.glassthought.com/notes/3ha01u9931je002miy86vdo)
    if (exc is CancellationException) {
      printlnWithTime("Cancellation Exception - rethrowing.")
    } else {
      printlnWithTime("Finished action=[$actionName], it THREW exception of type=[${exc::class.simpleName}] we are rethrowing it.")
    }
    throw exc
  }

  printlnWithTime("Finished action=[$actionName].")

  return result
}

class MyRuntimeException private constructor(msg: String) : RuntimeException(msg) {
  companion object {
    suspend fun create(msg: String): MyRuntimeException {
      val exc = MyRuntimeException(msg)

      printlnWithTime("throwing exception=[${exc::class.simpleName}] with msg=[${msg}]")

      return exc
    }
  }
}

fun printlnWithTime(msg: String) {
  println("[${Instant.now()}]: " + msg)
}

Original Code

package com.glassthought.sandbox

import com.glassthought.sandbox.util.out.impl.out
import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds


fun main(): Unit {
  val coroutineExceptionHandler = CoroutineExceptionHandler { _, ex ->
    runBlocking(CoroutineName("CoroutineExceptionHandler")) {
      out.error("Caught exception in CoroutineExceptionHandler: ${ex::class.simpleName} with message=[${ex.message}].")
    }
  }

  val mainJob = Job()
  val scope = CoroutineScope(mainJob + coroutineExceptionHandler)

  scope.launch(CoroutineName("SpawnedFromMain")) {
    out.actionWithMsg("foo", { foo(scope) })
  }
  
  runBlocking {
    out.actionWithMsg("mainJob.join()", { mainJob.join() })
  }
}

private suspend fun foo(scope: CoroutineScope) {
  val deferred = scope.async(CoroutineName("async-1")) {
    out.actionWithMsg("throw-exception", {
      throw MyRuntimeException.create("exception-from-async-before-it-got-to-await", out)
    }, delayDuration = 500.milliseconds)
  }

  out.info("Just launched co-routines.")
  out.actionWithMsg(
    "deferred.await()",
    {
      deferred.await()
    },
    delayDuration = 1.seconds
  )
}

Recorded output of command:

Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
[INFO][elapsed:   18ms][①][coroutname:@SpawnedFromMain#1] [->] action=[foo] is starting.
[INFO][elapsed:   18ms][⓶][coroutname:@coroutine#2] [->] action=[mainJob.join()] is starting.
[INFO][elapsed:   31ms][①][coroutname:@SpawnedFromMain#1]    Just launched co-routines.
[INFO][elapsed:   35ms][①][coroutname:@SpawnedFromMain#1]    [🐢] action=[deferred.await()] is being delayed for=[1000 ms] before starting.
[INFO][elapsed:   35ms][⓷][coroutname:@async-1#3] [🐢] action=[throw-exception] is being delayed for=[500 ms] before starting.
[INFO][elapsed:  535ms][⓷][coroutname:@async-1#3] [->] action=[throw-exception] is starting.
[WARN][elapsed:  557ms][⓷][coroutname:@async-1#3]    💥 throwing exception=[MyRuntimeException] with msg=[exception-from-async-before-it-got-to-await]
[WARN][elapsed:  557ms][⓷][coroutname:@async-1#3] [<-][💥] Finished action=[throw-exception], it THREW exception of type=[MyRuntimeException] we are rethrowing it.
[INFO][elapsed:  568ms][①][coroutname:@SpawnedFromMain#1] [<-][🫡] Cancellation Exception - rethrowing.
[INFO][elapsed:  569ms][⓶][coroutname:@coroutine#2] [<-] Finished action=[mainJob.join()].

Output colored per co-routine:

Image

Hi! The documentation is accurate, it's just that the term "uncaught exception" is coroutine-specific and may be unintuitive, as refers not just to exceptions that weren't processed with a try/catch but to whether they were propagated to the coroutine's parent. https://kotlinlang.org/docs/exception-handling.html describes the exact mechanics.

From the documentation you've quoted:

catches all its exceptions and represents them in the resulting [Deferred] object

This is the key to understanding what happens in the scenario you've presented. await always throws exceptions from the awaited Deferred object. async isn't even the important part: here's an example of this behavior that doesn't use async at all:

import kotlinx.coroutines.*

fun main() {
    val myDeferred = CompletableDeferred<Int>()
    myDeferred.completeExceptionally(IllegalArgumentException("failure"))
    runBlocking {
        println(runCatching {
            myDeferred.await()
        })
    }
}

prints

Failure(java.lang.IllegalArgumentException: failure)

Hi Dmitry @dkhalanskyjb

That is what I am trying to illustrate: that exceptions from async can propagate to the parent instead of going through Deferred. This edge case presents itself if the exception from async happens BEFORE the deferred object is awaited.

Here is another example that shows that the exceptions from async can go to the parent. Notice we NEVER await here but sibling gets cancelled, and parent scope rethrows the original exception, doesn't that qualify as exception from async propogating to the parent instead of going through Deferred object, if the parent is rethrowing the original exception?

package com.glassthought.sandbox

import kotlinx.coroutines.*

val startMillis = System.currentTimeMillis()


fun myPrint(msg: String) {
  val elapsed = System.currentTimeMillis() - startMillis

  println("[${elapsed.toString().padStart(3)} ms] $msg")
}

fun main() = runBlocking {

  coroutineScope {
    // Launch a coroutine that just waits, if parent were to be cancelled
    // this one would be cancelled too.
    val launchJob = launch(CoroutineName("launch-routine-just-waiting")) {
      try {
        myPrint("   [launch-routine-just-waiting] I am just waiting")
        delay(30000)
        myPrint("   [launch-routine-just-waiting] I am done waiting")

      } catch (e: CancellationException) {
        myPrint("   [launch-routine-just-waiting] I was cancelled :-(")

        throw e
      }
    }

    // Async
    val async = async(CoroutineName("async-coroutine")) {
      myPrint(" [async-coroutine]: I am going to throw in about 100ms")
      delay(100)
      myPrint(" [async-coroutine]: Throwing exception now!")

      throw RuntimeException("I am an exception from async coroutine")
    }

    myPrint("[main]: I am going to wait 500ms prior to checking launch job status, I am not awaiting on async")
    delay(500)
    myPrint(
      "[main]: launchJob isActive=${launchJob.isActive}, isCompleted=${launchJob.isCompleted}, isCancelled=${launchJob.isCancelled}"
    )
  }

  myPrint("FULLY DONE")
}

Output

[ 48 ms] [main]: I am going to wait 500ms prior to checking launch job status, I am not awaiting on async
[ 60 ms]    [launch-routine-just-waiting] I am just waiting
[ 60 ms]  [async-coroutine]: I am going to throw in about 100ms
[161 ms]  [async-coroutine]: Throwing exception now!
[183 ms]    [launch-routine-just-waiting] I was cancelled :-(
Exception in thread "main" java.lang.RuntimeException: I am an exception from async coroutine
        at com.glassthought.sandbox.MainKt$main$1$1$async$1.invokeSuspend(Main.kt:38)
        at _COROUTINE._BOUNDARY._(CoroutineDebugging.kt:46)
        at com.glassthought.sandbox.MainKt$main$1.invokeSuspend(Main.kt:16)
Caused by: java.lang.RuntimeException: I am an exception from async coroutine
        at com.glassthought.sandbox.MainKt$main$1$1$async$1.invokeSuspend(Main.kt:38)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:235)
        at kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:168)
        at kotlinx.coroutines.CancellableContinuationImpl.dispatchResume(CancellableContinuationImpl.kt:474)
        at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl(CancellableContinuationImpl.kt:508)
        at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$default(CancellableContinuationImpl.kt:497)
        at kotlinx.coroutines.CancellableContinuationImpl.resumeUndispatched(CancellableContinuationImpl.kt:595)
        at kotlinx.coroutines.EventLoopImplBase$DelayedResumeTask.run(EventLoop.common.kt:493)
        at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:280)
        at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)
        at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59)
        at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
        at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:38)
        at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
        at com.glassthought.sandbox.MainKt.main(Main.kt:14)
        at com.glassthought.sandbox.MainKt.main(Main.kt)

Yes, this is also true. As https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/async.html says,

it cancels the parent job (or outer scope) on failure to enforce structured concurrency paradigm. To change that behaviour, supervising parent (SupervisorJob or supervisorScope) can be used.

If async can propagate an exception to the parent, it will, in addition to marking its Deferred as having finished with an exception. However, if an exception can be propagated to the parent, it's no longer uncaught, so CoroutineExceptionHandler is irrelevant.

Another thing that's irrelevant is when exactly a Deferred is awaited and even if it's awaited at all. async only notifies its parent about the exception (if it can) and populates the Deferred, and completely independently from that, calling await on a Deferred will await its result (exceptional or not) in any part of the program, whether from a parent, a sibling, or just any other coroutine.

My advice is to use Stack Overflow or the Kotlinlang Slack for getting answers about kotlinx.coroutines. These place are pretty active, and you will get answers much faster than you would here. When it turns out the documentation is actually wrong, misleading, or can be reworded better, sure, this is an issue worth filing. Every behavior you've shown so far is documented, though.

Sure I will post on stack overflow to clarify this as well.

However, the current vocabulary around exceptions as you have stated is quite un-intuitive and is in violation of POLS (Principle of Least Surprise), as the term that has intuitive meaning uncaught exceptions has been overloaded to mean something else, uncaught exceptions in kotlin coroutines now really mean uncaight exceptions from launch rather than just what would one presume is "uncaught exception" (Exceptions that is has not been explicitly caught).

Doc correct but misleading

Per this documentation that you referenced:

Coroutine builders come in two flavors: propagating exceptions automatically (launch) or exposing them to users (async and produce). When these builders are used to create a root coroutine, that is not a child of another coroutine, the former builders [launch] treat exceptions as uncaught exceptions, similar to Java's Thread.uncaughtExceptionHandler, while the latter are relying on the user to consume the final exception, for example via await or receive (produce and receive are covered in Channels section). - exception-propagation

So yes, per documentation of CoroutineExceptionHandler does what it is supposed to. It catches only unhandled exceptions from launch.

CoroutineExceptionHandler: An optional element in the coroutine context to handle uncaught exceptions. - CoroutineExceptionHandler

And given that uncaught exception in kotlin coroutine vocabulary means: unhandled exception from launch, CoroutineExceptionHandler would very much benefit from documentation adjustment to clearly state:

CoroutineExceptionHandler: An optional element in the coroutine context to handle uncaught exception from launch.

As otherwise the current documentation makes it very misleading and would lean people to expect CoroutineExceptionHandler to trigger if nobody had the chance to await() on the deferred object.

For example in below code, we don't have coroutineScope error boundary parent, and due to delay we don't get to await() before being cancelled. If someone were to read CoroutineExceptionHandler documentation and see uncaught exceptions they would very well expect CoroutineExceptionHandler to trigger in this case. But it won't since CoroutineExceptionHandler only triggers on uncaught exceptions from launch not all uncaught exceptions. And original exception will not have a fail safe hook catch to be processed.

package com.glassthought.sandbox

import kotlinx.coroutines.*

val startMillis = System.currentTimeMillis()


fun myPrint(msg: String) {
  val elapsed = System.currentTimeMillis() - startMillis

  println("[${elapsed.toString().padStart(3)} ms] $msg")
}

fun main() {
  myPrint("[MAIN] START...")

  val mainScopeJob = Job()
  val mainScope = CoroutineScope(
    mainScopeJob
        + Dispatchers.IO
        + CoroutineExceptionHandler { _, throwable -> myPrint("👍 CoroutineExceptionHandler invoked exc.message=[${throwable.message}]👍 ") }
        + CoroutineName("main-scope")
  )

  val async = mainScope.async(CoroutineName("async-coroutine")) {
    myPrint(" [async-coroutine]: I am going to throw in about 100ms")
    delay(100)
    myPrint(" [async-coroutine]: Throwing exception now!")

    throw RuntimeException("I am an exception from async coroutine")
  }

  val waiter = mainScope.launch {
    try {
      myPrint("   [launch-routine-just-waiting] I am going to wait 500 millis and then await")
      delay(500)
      async.await()
    } catch (e: CancellationException) {
      myPrint("   [launch-routine-just-waiting] I was cancelled! CancellationException.message=[${e.message}]")
      throw e
    } catch (e: Exception) {
      myPrint("   [launch-routine-just-waiting] ✅ I caught an exception! Exception.message=[${e.message}]")
    }
  }


  runBlocking {
    mainScopeJob.join()
  }

  myPrint("[MAIN] DONE.")
}
[  9 ms] [MAIN] START...
[ 58 ms]  [async-coroutine]: I am going to throw in about 100ms
[ 58 ms]    [launch-routine-just-waiting] I am going to wait 500 millis and then await
[161 ms]  [async-coroutine]: Throwing exception now!
[187 ms]    [launch-routine-just-waiting] I was cancelled! CancellationException.message=[Parent job is Cancelling]
[188 ms] [MAIN] DONE.

Another example of misleading doc

A coroutine that was created using async always catches all its exceptions and represents them in the resulting Deferred object, so it cannot result in uncaught exceptions. - kdoc

As example above demonstrates even though the exception is caught into the Deferred object nobody has access to that deferred object on time, so logically it would presume to be called "uncaught exception" but its not.

So yes, per documentation of CoroutineExceptionHandler does what it is supposed to. It catches only unhandled exceptions from launch.

Not quite. It handles uncaught exceptions, and those typically result from launch.

And given that uncaught exception in kotlin coroutine vocabulary means: unhandled exception from launch, CoroutineExceptionHandler would very much benefit from documentation adjustment to clearly state:

Nope, "uncaught exception" means, to rephrase the documentation, "an exception that can not be propagated to the root via structured concurrency and also can't be returned through some normal communication between coroutines". launch without a lexically scoped root coroutine is a prime example of that, but not the only one:

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() {
    GlobalScope.produce<Int>(CoroutineExceptionHandler { _, ex -> println("Got an exception: $ex") }) {
        close(IllegalArgumentException())
        throw IllegalStateException()
    }
    Thread.sleep(100)
}

Usually, produce propagates the exception thrown in its block by closing its channel with it, but here, the channel is already closed with another exception, so we get

Got an exception: java.lang.IllegalStateException

It's an uncaught exception.

I've noticed while writing this that the produce documentation doesn't mention this behavior, even though it clearly should. I'll fix it.

await()

As I mentioned above, await() is irrelevant to this, as async's exceptioins are never considered uncaught, which is stated in async's documentation. It's the responsibility of the consumer of the Deferred to query it.

the term that has intuitive meaning uncaught exceptions has been overloaded to mean something else

I agree that it's confusing, yes. I'm not sure changing the term at this point will help more than it will add extra confusion, though, given how widespread it already is: https://www.google.com/search?q=coroutines+%22uncaught+exceptions%22

Got it. Thanks for further clarification around produce.


On await:

It's the responsibility of the consumer of the Deferred to query it.

While I agree that per documentation of async it's responsibility of await to query it, we might not have the chance to query the await() if the co-routine that is suppose to perform await gets cancelled. In which case we loose the async exception that occurred (which doesn't appear to be in the spirit of co-routines structured concurrency).

If we don't want to loose any exceptions from async, with the current implementation we either have to catch and handle exceptions per individual async, having some common wrapper for async spawning to do that for us, or have to use SupervisorJob to make sure the await always has a chance to run. Well will have to adjust that CoroutineExceptionHandler only works for part of the exceptions, thanks for all the clarifications!


I agree that it's confusing, yes. I'm not sure changing the term at this point will help more than it will add extra confusion,

I agree that changing the term now would likely just add extra confusion. What would be helpful and non-intrusive is having a page dedicated to the definition of uncaught exception in the context of kotlin co-routines. So that whenever that term is used throughout the documentation in pages like

Instead of just making the uncaught exception bold, it is linked to the page which clearly describes the meaning of uncaught exception in the context of kotlin coroutines.