Skip to content

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

@nickolay-kondratyev

Description

@nickolay-kondratyev

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions