⚠️CoroutineExceptionHandler: does-not-work-with-async⚠️

async & CoroutineExceptionHandler: CoroutineExceptionHandler with async will be ignored. AWAIT rethrows the exception - ⚠️🐛⚠️

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

Code

package com.glassthought.sandbox

import gt.sandbox.util.output.Emojis
import gt.sandbox.util.output.Out
import kotlinx.coroutines.*
import kotlin.system.exitProcess
import kotlin.time.Duration.Companion.milliseconds

fun main(): Unit = runBlocking {
  val out = Out.standard()
  out.info("START")

  try {
    mainImpl(out)
  } catch (e: Exception) {
    out.error("in main got an exception! of type=[${e::class.simpleName}] with msg=[${e.message}] cause=[${e.cause}]. Exiting with error code 1")

    exitProcess(1)
  }

  out.info("DONE - WITHOUT errors on main")
}

private suspend fun mainImpl(out: Out) {

  // CoroutineExceptionHandler - will be IGNORED with async - ⚠️🐛⚠️
  // This handler will NEVER be called because async doesn't use it
  val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
    // This block will NEVER execute with async
    println("❌ CoroutineExceptionHandler called (this won't happen): ${throwable.message}")
  }

  // Create a scope with the exception handler
  // Note: Using just coroutineExceptionHandler for clarity (no SupervisorJob needed for this demo)
  val scope = CoroutineScope(coroutineExceptionHandler)

  out.info("Creating async coroutine...")

  val delayInChild = 500.milliseconds
  val deferredResult = scope.async {
    out.info("Async coroutine started, will throw exception after delay...")
    delay(delayInChild)
    throw MyExceptionWillThrowFromCoroutine.create("async-exception-msg", out)
  }

  out.info("${Emojis.BUG}: CoroutineExceptionHandler is installed but will NEVER be called for async")
  out.info("Reason: async stores exceptions until await() is called, doesn't propagate to handler")

  // Give async time to complete and throw internally
  out.delayNamed(delayInChild * 2, "giving async time to complete and store exception")

  // The exception is stored in the Deferred, not propagated to handler
  out.info("Async has completed. Exception is stored in Deferred.")
  out.infoPrintState(deferredResult, "deferredResult")

  // This will rethrow the stored exception to the caller
  out.info("Calling await() - this will rethrow the stored exception...")
  try {
    deferredResult.await()
    out.error("Should never reach here - await() should throw")
  } catch (e: Exception) {
    out.warn("✅ Exception caught by try-catch (NOT by CoroutineExceptionHandler): ${e.message}")
    out.info("This shows CoroutineExceptionHandler doesn't work with async/await")
  }

  out.info("Demonstration complete - handler was never called")
}

class MyExceptionWillThrowFromCoroutine private constructor(msg: String) : RuntimeException(msg) {
  companion object {
    suspend fun create(msg: String, out: Out): MyExceptionWillThrowFromCoroutine {
      val exc = MyExceptionWillThrowFromCoroutine(msg)

      out.warn("${Emojis.EXCEPTOIN} throwing exception=[${exc::class.simpleName}] with msg=[${msg}]")

      return exc
    }
  }
}

Command to reproduce:

gt.sandbox.checkout.commit 75b9c9b740f35ad9b8b7 \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"

Recorded output of command:

Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
[INFO][elapsed:   15ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] START
[INFO][elapsed:   30ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] Creating async coroutine...
[INFO][elapsed:   36ms][2️⃣][⓶][coroutname:@coroutine#2][tname:DefaultDispatcher-worker-1/tid:31] Async coroutine started, will throw exception after delay...
[INFO][elapsed:   36ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] ⚠️�oroutineExceptionHandler is installed but will NEVER be called for async
[INFO][elapsed:   36ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] Reason: async stores exceptions until await() is called, doesn't propagate to handler
[INFO][elapsed:   38ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] Delaying for 1s what_for=[giving async time to complete and store exception]
[WARN][elapsed:  560ms][2️⃣][⓶][coroutname:@coroutine#2][tname:DefaultDispatcher-worker-1/tid:31] 💥  throwing exception=[MyExceptionWillThrowFromCoroutine] with msg=[async-exception-msg]
[INFO][elapsed: 1039ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] Done delaying for 1s what_for=[giving async time to complete and store exception]
[INFO][elapsed: 1039ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] Async has completed. Exception is stored in Deferred.
[INFO][elapsed: 1044ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] State of [deferredResult]:
deferredResult.isActive    | false
deferredResult.isCancelled | true
deferredResult.isCompleted | true

[INFO][elapsed: 1045ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] Calling await() - this will rethrow the stored exception...
[WARN][elapsed: 1048ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] ✅ Exception caught by try-catch (NOT by CoroutineExceptionHandler): async-exception-msg
[INFO][elapsed: 1048ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] This shows CoroutineExceptionHandler doesn't work with async/await
[INFO][elapsed: 1048ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] Demonstration complete - handler was never called
[INFO][elapsed: 1048ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] DONE - WITHOUT errors on main

If async fails before await

If async fails before await: The exception appears to be lost and still not processed by CoroutineExceptionHandler

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
  )
}

Command to reproduce:

gt.sandbox.checkout.commit 0423eb04f6570cb822dd \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"

Recorded output of command:

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

Filed github issue on this

Documentation needs adjustments: states async cannot cause uncaught exceptions but async can if fails before it is awaited. · Issue #4504 · Kotlin/kotlinx.coroutines


Backlinks