⚠️ 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.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()].
GT-Sandbox-Snapshot: Example where awaiting co-routine got cancelled before getting to await
Code
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.")
}
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.debug
Picked 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.
Related Patterns to combat this
- Pattern: Getting Nice To Have Data In Async Without Crashing Required Data Get (Using supervisorScope) - if you use supervisor scope then exception from async does NOT crash shutdown the parent, and hence, you can make sure
await()
is called.
Backlinks