Skip to content

Commit cf24a84

Browse files
committed
Basic exception stacktrace recovery mechanism
* Implement CoroutineStackFrame in CancellableContinuationImpl, DispatchedContinuation and ScopeCoroutine * On coroutine resumption try to reflectively instantiate exception instance of the same type, but with augmented stacktrace * Recover stacktrace by walking over CoroutineStackFrame * Recover stacktrace on fast-path exceptions without CoroutineStackFrame walking to provide more context to an exception * Unwrap exceptions when doing aggregation in JobSupport * Add kill-switch to disable stacktrace recovery, introduce method to recover stacktrace on the exceptional fast-path * Add `suspendCoroutineOrReturn` on exceptional fast-path in await in order to provide "real" stacktrace Design rationale: All recovery of *suspended* continuations takes place in Dispatched.kt file, the only place where all calls to "resume*" ends up, so we don't have to remember about stacktrace recovery in every primitive we are implementing. But to provide more accurate stacktraces we *have to* recover it on every fast-path for better debuggability. Fixes #493
1 parent 1032f58 commit cf24a84

File tree

5 files changed

+313
-0
lines changed

5 files changed

+313
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.internal
6+
7+
import kotlin.coroutines.*
8+
9+
/**
10+
* Tries to recover stacktrace for given [exception] and [continuation].
11+
* Stacktrace recovery tries to restore [continuation] stack frames using its debug metadata with [CoroutineStackFrame] API
12+
* and then reflectively instantiate exception of given type with original exception as a cause and
13+
* sets new stacktrace for wrapping exception.
14+
* Some frames may be missing due to tail-call elimination.
15+
*
16+
* Works only on JVM with enabled debug-mode.
17+
*/
18+
internal expect fun <E: Throwable> recoverStackTrace(exception: E, continuation: Continuation<*>): E
19+
20+
/**
21+
* Tries to recover stacktrace for given [exception]. Used in non-suspendable points of awaiting.
22+
* Stacktrace recovery tries to instantiate exception of given type with original exception as a cause.
23+
* Wrapping exception will have proper stacktrace as it's instantiated in the right context.
24+
*
25+
* Works only on JVM with enabled debug-mode.
26+
*/
27+
internal expect fun <E: Throwable> recoverStackTrace(exception: E): E
28+
29+
// Name conflict with recoverStackTrace
30+
@Suppress("NOTHING_TO_INLINE")
31+
internal expect suspend inline fun recoverAndThrow(exception: Throwable): Nothing
32+
33+
/**
34+
* The opposite of [recoverStackTrace].
35+
* It is guaranteed that `unwrap(recoverStackTrace(e)) === e`
36+
*/
37+
internal expect fun <E: Throwable> unwrap(exception: E): E
38+
39+
expect class StackTraceElement
40+
41+
internal expect interface CoroutineStackFrame {
42+
public val callerFrame: CoroutineStackFrame?
43+
public fun getStackTraceElement(): StackTraceElement?
44+
}
45+
46+
/**
47+
* Marker that indicates that stacktrace of the exception should not be recovered.
48+
* Currently internal, but may become public in the future
49+
*/
50+
internal interface NonRecoverableThrowable
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
* Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.internal
6+
7+
import kotlinx.coroutines.*
8+
import java.util.*
9+
import kotlin.coroutines.*
10+
import kotlin.coroutines.intrinsics.*
11+
12+
internal actual fun <E : Throwable> recoverStackTrace(exception: E): E {
13+
if (recoveryDisabled(exception)) {
14+
return exception
15+
}
16+
17+
val copy = tryCopyException(exception) ?: return exception
18+
return copy.sanitizeStackTrace()
19+
}
20+
21+
private fun <E : Throwable> E.sanitizeStackTrace(): E {
22+
val size = stackTrace.size
23+
24+
var lastIntrinsic = -1
25+
for (i in 0 until size) {
26+
val name = stackTrace[i].className
27+
if ("kotlinx.coroutines.internal.ExceptionsKt" == name) {
28+
lastIntrinsic = i
29+
}
30+
}
31+
32+
val startIndex = lastIntrinsic + 1
33+
val trace = Array(size - lastIntrinsic) {
34+
if (it == 0) {
35+
artificialFrame("Current coroutine stacktrace")
36+
} else {
37+
stackTrace[startIndex + it - 1]
38+
}
39+
}
40+
41+
stackTrace = trace
42+
return this
43+
}
44+
45+
internal actual fun <E : Throwable> recoverStackTrace(exception: E, continuation: Continuation<*>): E {
46+
if (recoveryDisabled(exception) || continuation !is CoroutineStackFrame) {
47+
return exception
48+
}
49+
50+
return recoverFromStackFrame(exception, continuation)
51+
}
52+
53+
private fun <E : Throwable> recoverFromStackFrame(exception: E, continuation: CoroutineStackFrame): E {
54+
val newException = tryCopyException(exception) ?: return exception
55+
val stacktrace = createStackTrace(continuation)
56+
if (stacktrace.isEmpty()) return exception
57+
stacktrace.add(0, artificialFrame("Current coroutine stacktrace"))
58+
newException.stackTrace = stacktrace.toTypedArray()
59+
return newException
60+
}
61+
62+
63+
@Suppress("NOTHING_TO_INLINE")
64+
internal actual suspend inline fun recoverAndThrow(exception: Throwable): Nothing {
65+
if (recoveryDisabled(exception)) throw exception
66+
suspendCoroutineUninterceptedOrReturn<Nothing> {
67+
if (it !is CoroutineStackFrame) throw exception
68+
throw recoverFromStackFrame(exception, it)
69+
}
70+
}
71+
72+
internal actual fun <E : Throwable> unwrap(exception: E): E {
73+
if (recoveryDisabled(exception)) {
74+
return exception
75+
}
76+
77+
val element = exception.stackTrace.firstOrNull() ?: return exception
78+
if (element.isArtificial()) {
79+
@Suppress("UNCHECKED_CAST")
80+
return exception.cause as? E ?: exception
81+
} else {
82+
return exception
83+
}
84+
}
85+
86+
private fun <E : Throwable> recoveryDisabled(exception: E) =
87+
!RECOVER_STACKTRACE || !DEBUG || exception is CancellationException || exception is NonRecoverableThrowable
88+
89+
@Suppress("UNCHECKED_CAST")
90+
private fun <E : Throwable> tryCopyException(exception: E): E? {
91+
/*
92+
* Try to reflectively find constructor(), constructor(message, cause) or constructor(cause).
93+
* Exceptions are shared among coroutines, so we should copy exception before recovering current stacktrace.
94+
*/
95+
var newException: E? = null
96+
try {
97+
val constructors = exception.javaClass.constructors.sortedByDescending { it.parameterTypes.size }
98+
for (constructor in constructors) {
99+
val parameters = constructor.parameterTypes
100+
if (parameters.size == 2 && parameters[0] == String::class.java && parameters[1] == Throwable::class.java) {
101+
newException = constructor.newInstance(exception.message, exception) as E
102+
} else if (parameters.size == 1 && parameters[0] == Throwable::class.java) {
103+
newException = constructor.newInstance(exception) as E
104+
} else if (parameters.isEmpty()) {
105+
newException = (constructor.newInstance() as E).also { it.initCause(exception) }
106+
}
107+
108+
if (newException != null) {
109+
break
110+
}
111+
}
112+
} catch (e: Exception) {
113+
// Do nothing
114+
}
115+
return newException
116+
}
117+
118+
private fun createStackTrace(continuation: CoroutineStackFrame): ArrayList<StackTraceElement> {
119+
val stack = ArrayList<StackTraceElement>()
120+
continuation.getStackTraceElement()?.let { stack.add(it) }
121+
122+
var last = continuation
123+
while (true) {
124+
last = (last as? CoroutineStackFrame)?.callerFrame ?: break
125+
last.getStackTraceElement()?.let { stack.add(it) }
126+
}
127+
return stack
128+
}
129+
130+
131+
internal fun artificialFrame(message: String) = java.lang.StackTraceElement("\b\b\b($message", "\b", "\b", -1)
132+
internal fun StackTraceElement.isArtificial() = className.startsWith("\b\b\b")
133+
134+
@Suppress("ACTUAL_WITHOUT_EXPECT")
135+
actual typealias CoroutineStackFrame = kotlin.coroutines.jvm.internal.CoroutineStackFrame
136+
137+
actual typealias StackTraceElement = java.lang.StackTraceElement
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.exceptions
6+
7+
import kotlinx.coroutines.*
8+
import org.junit.Test
9+
import kotlin.test.*
10+
11+
class StackTraceRecoveryInHierarchiesTest : TestBase() {
12+
13+
@Test
14+
fun testNestedAsync() = runTest {
15+
val rootAsync = async(NonCancellable) {
16+
expect(1)
17+
18+
// Just a noise for unwrapping
19+
async {
20+
expect(2)
21+
delay(Long.MAX_VALUE)
22+
}
23+
24+
// Do not catch, fail on cancellation
25+
async {
26+
expect(3)
27+
async {
28+
expect(4)
29+
delay(Long.MAX_VALUE)
30+
}
31+
32+
async {
33+
expect(5)
34+
// 1) await(), catch, verify and rethrow
35+
try {
36+
val nested = async {
37+
expect(6)
38+
throw RecoverableTestException()
39+
}
40+
41+
nested.awaitNested()
42+
} catch (e: RecoverableTestException) {
43+
expect(7)
44+
e.verifyException(
45+
"await\$suspendImpl",
46+
"awaitNested",
47+
"\$testNestedAsync\$1\$rootAsync\$1\$2\$2.invokeSuspend"
48+
)
49+
// Just rethrow it
50+
throw e
51+
}
52+
}
53+
}
54+
}
55+
56+
try {
57+
rootAsync.awaitRootLevel()
58+
} catch (e: RecoverableTestException) {
59+
e.verifyException("await\$suspendImpl", "awaitRootLevel")
60+
finish(8)
61+
}
62+
}
63+
64+
private suspend fun Deferred<*>.awaitRootLevel() {
65+
await()
66+
assertTrue(true)
67+
}
68+
69+
private suspend fun Deferred<*>.awaitNested() {
70+
await()
71+
assertTrue(true)
72+
}
73+
74+
private fun RecoverableTestException.verifyException(vararg expectedTraceElements: String) {
75+
// It is "recovered" only once
76+
assertEquals(1, depth())
77+
val stacktrace = stackTrace.map { it.methodName }.toSet()
78+
assertTrue(expectedTraceElements.all { stacktrace.contains(it) })
79+
}
80+
81+
private fun Throwable.depth(): Int {
82+
val cause = cause ?: return 0
83+
return 1 + cause.depth()
84+
}
85+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.internal
6+
7+
import kotlin.coroutines.*
8+
9+
internal actual fun <E: Throwable> recoverStackTrace(exception: E, continuation: Continuation<*>): E = exception
10+
internal actual fun <E: Throwable> recoverStackTrace(exception: E): E = exception
11+
internal actual suspend inline fun recoverAndThrow(exception: Throwable): Nothing = throw exception
12+
13+
internal actual fun <E : Throwable> unwrap(exception: E): E = exception
14+
15+
@Suppress("unused")
16+
internal actual interface CoroutineStackFrame {
17+
public actual val callerFrame: CoroutineStackFrame?
18+
public actual fun getStackTraceElement(): StackTraceElement?
19+
}
20+
21+
actual typealias StackTraceElement = Any
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.internal
6+
7+
import kotlin.coroutines.*
8+
9+
internal actual fun <E: Throwable> recoverStackTrace(exception: E, continuation: Continuation<*>): E = exception
10+
internal actual fun <E: Throwable> recoverStackTrace(exception: E): E = exception
11+
internal actual fun <E : Throwable> unwrap(exception: E): E = exception
12+
internal actual suspend inline fun recoverAndThrow(exception: Throwable): Nothing = throw exception
13+
14+
@Suppress("unused")
15+
internal actual interface CoroutineStackFrame {
16+
public actual val callerFrame: CoroutineStackFrame?
17+
public actual fun getStackTraceElement(): StackTraceElement?
18+
}
19+
20+
actual typealias StackTraceElement = Any

0 commit comments

Comments
 (0)