Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to get full exception stacktrace when using await() on CompletableFuture

Problem

I am using kotlinx.coroutines.future.FutureKt#await to await for an async code. But when any exception is thrown from this async code, the Exception doesn't contain full stack call. E.g.:

fun main() {
    try {
        myFun1Blocking()
    } catch (e: Throwable) {
        e.printStackTrace(System.out)
    }
}

fun myFun1Blocking() {
    runBlocking {
        myFun2Suspend()
    }
}

suspend fun myFun2Suspend() {
    runAsync().await()
}

fun runAsync(): CompletableFuture<Void> {
    return CompletableFuture.runAsync {
        Thread.sleep(2000)
        throw Exception()
    }
}

This results in the following output:

java.lang.Exception
    at TestKotlinKt.runAsync$lambda-0(TestKotlin.kt:34)
    at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1736)
    at java.base/java.util.concurrent.CompletableFuture$AsyncRun.exec(CompletableFuture.java:1728)
    at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:290)
    at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1020)
    at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1656)
    at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1594)
    at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:183)

The stack trace only has the runAsync method part.

Solution #1

While trying to workaround this problem, I first thought of catching it just outside of await:

suspend fun <T> CompletionStage<T>.awaitWithException(): T {
    try {
        return await()
    } catch (e: Exception) {
        throw Exception(e)
    }
}
java.lang.Exception: java.lang.Exception
    at TestKotlinKt.awaitWithException(TestKotlin.kt:36)
    at TestKotlinKt$awaitWithException$1.invokeSuspend(TestKotlin.kt)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
    at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:274)
    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 TestKotlinKt.myFun1Blocking(TestKotlin.kt:23)
    at TestKotlinKt.main(TestKotlin.kt:16)
    at TestKotlinKt.main(TestKotlin.kt)
Caused by: java.lang.Exception
    at TestKotlinKt.runAsync$lambda-0(TestKotlin.kt:43)
    at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1736)
    at java.base/java.util.concurrent.CompletableFuture$AsyncRun.exec(CompletableFuture.java:1728)
    at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:290)
    at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1020)
    at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1656)
    at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1594)
    at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:183)

Better, but I still lost the myFun2Suspend call in the stack.

Solution #2

Then I tried saving the stacktrace just before await():

suspend fun <T> CompletionStage<T>.awaitWithException(printStream: PrintStream): T {
    val throwable = Throwable("Await Exception")
    try {
        return await()
    } catch (e: Exception) {
        throwable.printStackTrace(printStream)
        throw e
    }
}
java.lang.Throwable: Await Exception
    at TestKotlinKt.awaitWithException(TestKotlin.kt:43)
    at TestKotlinKt.myFun2Suspend(TestKotlin.kt:31)
    at TestKotlinKt$myFun1Blocking$1.invokeSuspend(TestKotlin.kt:26)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:274)
    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 TestKotlinKt.myFun1Blocking(TestKotlin.kt:25)
    at TestKotlinKt.main(TestKotlin.kt:18)
    at TestKotlinKt.main(TestKotlin.kt)
AsyncException
    at TestKotlinKt.runAsync$lambda-0(TestKotlin.kt:55)
    at java.base/java.util.concurrent.CompletableFuture$AsyncRun.run(CompletableFuture.java:1736)
    at java.base/java.util.concurrent.CompletableFuture$AsyncRun.exec(CompletableFuture.java:1728)
    at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:290)
    at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1020)
    at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1656)
    at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1594)
    at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:183)

Now I see everything.

All in all, my solution seems to be very hacky. Is there something better?

like image 296
Kirill Kosolapov Avatar asked May 25 '26 23:05

Kirill Kosolapov


2 Answers

I faced the same problem. The Kotlin coroutine debug library didn't help me in any way. Therefore, after studying the implementation of coroutines, I wrote my own solution based on bytecode generation and MethodHandle API. It supports JVM 1.8 and Android API 25 or higher. I called it Stacktrace-decoroutinator.

The reason why the stacktrace is lost is that when the coroutine wakes up, only the last method of its call stack is called.

My library replaces the coroutine awakening implementation. It generates classes at runtime with names that match the entire coroutine call stack.

These classes don't do anything except call each other in the coroutine call stack sequence.

Thus, if the coroutine throws an exception, they mimic the real call stack of the coroutine during the creation of the exception stacktrace.

like image 198
Denis Avatar answered May 28 '26 11:05

Denis


Referring to Kotlin's documentation about debugging both of your solutions have officially supported counterparts:

  • Set the system property kotlinx.coroutines.debug to on to enable debug mode. This enables stacktrace recovery which is a more comprehensive version of solution #1.
  • Use the Kotlin debug agent to enable creation stacktraces which is the official version of solution #2. Do be aware that this is a very expensive feature because it will need to dump stack traces each time a coroutine is created.

In theory kotlin debug mode should be enough since the exception has to unwind through the 'stack' of coroutines. It just isn't the prettiest solution.

like image 33
Kiskae Avatar answered May 28 '26 12:05

Kiskae