⚠️CoroutineExceptionHandler: does NOT work with async⚠️
3 min read
⚠️ 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.sandboximport gt.sandbox.util.output.Emojisimport gt.sandbox.util.output.Outimport kotlinx.coroutines.*import kotlin.system.exitProcessimport kotlin.time.Duration.Companion.millisecondsfun 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.debugPicked 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 | falsedeferredResult.isCancelled | truedeferredResult.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 throws unhandled exception before await THEN exception is lost⚠️
If async throws before await: The exception is lost and is NOT processed by CoroutineExceptionHandler
Code
package com.glassthought.sandboximport com.glassthought.sandbox.util.out.impl.outimport kotlinx.coroutines.*import kotlin.time.Duration.Companion.millisecondsimport kotlin.time.Duration.Companion.secondsfun 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.debugPicked 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()].
GT-Sandbox-Snapshot: Example where awaiting co-routine got cancelled before getting to await
Code
package com.glassthought.sandboximport 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.")}
Command to reproduce:
gt.sandbox.checkout.commit 1b939780478f3d10a020 \&& cd "${GT_SANDBOX_REPO}" \&& cmd.run.announce "./gradlew run --quiet"
Recorded output of command:
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debugPicked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug[ 7 ms] [MAIN] START...[ 43 ms] [launch-routine-just-waiting] I am going to wait 500 millis and then await[ 43 ms] [async-coroutine]: I am going to throw in about 100ms[145 ms] [async-coroutine]: Throwing exception now![167 ms] [launch-routine-just-waiting] I was cancelled! CancellationException.message=[Parent job is Cancelling][168 ms] [MAIN] DONE.