Skip to content

Commit c99c93a

Browse files
committed
~: merge recovered stacktraces, optimizations
1 parent a0464a8 commit c99c93a

File tree

7 files changed

+272
-83
lines changed

7 files changed

+272
-83
lines changed

common/kotlinx-coroutines-core-common/test/TestBase.common.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public class TestException1(message: String? = null) : Throwable(message), NonRe
2929
public class TestException2(message: String? = null) : Throwable(message), NonRecoverableThrowable
3030
public class TestException3(message: String? = null) : Throwable(message), NonRecoverableThrowable
3131
public class TestRuntimeException(message: String? = null) : RuntimeException(message), NonRecoverableThrowable
32-
public class RecoverableTestException(message: String? = null) : Throwable(message)
32+
public class RecoverableTestException(message: String? = null) : RuntimeException(message)
3333

3434
public fun wrapperDispatcher(context: CoroutineContext): CoroutineContext {
3535
val dispatcher = context[ContinuationInterceptor] as CoroutineDispatcher

core/kotlinx-coroutines-core/src/internal/Exceptions.kt

+66-11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
33
*/
44

5+
@file:Suppress("UNCHECKED_CAST")
6+
57
package kotlinx.coroutines.internal
68

79
import kotlinx.coroutines.*
@@ -13,7 +15,7 @@ internal actual fun <E : Throwable> recoverStackTrace(exception: E): E {
1315
if (recoveryDisabled(exception)) {
1416
return exception
1517
}
16-
18+
// No unwrapping on continuation-less path: exception is not reported multiple times via slow paths
1719
val copy = tryCopyException(exception) ?: return exception
1820
return copy.sanitizeStackTrace()
1921
}
@@ -22,9 +24,9 @@ private fun <E : Throwable> E.sanitizeStackTrace(): E {
2224
val stackTrace = stackTrace
2325
val size = stackTrace.size
2426

25-
val lastIntrinsic = stackTrace.indexOfFirst { "kotlinx.coroutines.internal.ExceptionsKt" == it.className }
27+
val lastIntrinsic = stackTrace.frameIndex("kotlinx.coroutines.internal.ExceptionsKt")
2628
val startIndex = lastIntrinsic + 1
27-
val endIndex = stackTrace.indexOfFirst { "kotlin.coroutines.jvm.internal.BaseContinuationImpl" == it.className }
29+
val endIndex = stackTrace.frameIndex("kotlin.coroutines.jvm.internal.BaseContinuationImpl")
2830
val adjustment = if (endIndex == -1) 0 else size - endIndex
2931
val trace = Array(size - lastIntrinsic - adjustment) {
3032
if (it == 0) {
@@ -47,15 +49,68 @@ internal actual fun <E : Throwable> recoverStackTrace(exception: E, continuation
4749
}
4850

4951
private fun <E : Throwable> recoverFromStackFrame(exception: E, continuation: CoroutineStackFrame): E {
50-
val newException = tryCopyException(exception) ?: return exception
52+
/*
53+
* Here we are checking whether exception has already recovered stacktrace.
54+
* If so, we extract initial and merge recovered stacktrace and current one
55+
*/
56+
val (cause, recoveredStacktrace) = exception.causeAndStacktrace()
57+
58+
// Try to create new exception of the same type and get stacktrace from continuation
59+
val newException = tryCopyException(cause) ?: return exception
5160
val stacktrace = createStackTrace(continuation)
5261
if (stacktrace.isEmpty()) return exception
53-
val copied = meaningfulActualStackTrace(exception)
54-
stacktrace.add(0, artificialFrame("Current coroutine stacktrace"))
55-
newException.stackTrace = (copied + stacktrace).toTypedArray() // TODO optimizable
62+
63+
// Merge if necessary
64+
if (cause !== exception) {
65+
mergeRecoveredTraces(recoveredStacktrace, stacktrace)
66+
}
67+
68+
/*
69+
* Here we partially copy original exception stacktrace to make current one much prettier.
70+
* E.g. for
71+
* ```
72+
* fun foo() = async { error(...) }
73+
* suspend fun bar() = foo().await()
74+
* ```
75+
* we would like to produce following exception:
76+
* IllegalStateException
77+
* at foo
78+
* at kotlinx.coroutines.resumeWith
79+
* (Current coroutine stacktrace)
80+
* at bar
81+
* ...real stacktrace...
82+
* caused by "IllegalStateException" (original one)
83+
*/
84+
// TODO optimizable allocations and passes
85+
stacktrace.addFirst(artificialFrame("Current coroutine stacktrace"))
86+
val copied = meaningfulActualStackTrace(cause)
87+
newException.stackTrace = (copied + stacktrace).toTypedArray()
5688
return newException
5789
}
5890

91+
/**
92+
* Find initial cause of the exception without restored stacktrace.
93+
* Returns intermediate stacktrace as well in order to avoid excess cloning of array as an optimization.
94+
*/
95+
private fun <E : Throwable> E.causeAndStacktrace(): Pair<E, Array<StackTraceElement>> {
96+
val cause = cause
97+
return if (cause != null && cause.javaClass == javaClass) {
98+
val currentTrace = stackTrace
99+
if (currentTrace.any { it.isArtificial() })
100+
cause as E to currentTrace
101+
else this to emptyArray()
102+
} else {
103+
this to emptyArray()
104+
}
105+
}
106+
107+
private fun mergeRecoveredTraces(recoveredStacktrace: Array<StackTraceElement>, result: ArrayDeque<StackTraceElement>) {
108+
val startIndex = recoveredStacktrace.indexOfFirst { it.isArtificial() } + 1
109+
for (i in (recoveredStacktrace.size - 1) downTo startIndex) {
110+
result.addFirst(recoveredStacktrace[i])
111+
}
112+
}
113+
59114
/*
60115
* Returns slice of the original stacktrace from the original exception.
61116
* E.g. for
@@ -70,12 +125,11 @@ private fun <E : Throwable> recoverFromStackFrame(exception: E, continuation: Co
70125
*/
71126
private fun <E : Throwable> meaningfulActualStackTrace(exception: E): List<StackTraceElement> {
72127
val stackTrace = exception.stackTrace
73-
val index = stackTrace.indexOfFirst { "kotlin.coroutines.jvm.internal.BaseContinuationImpl" == it.className }
128+
val index = stackTrace.frameIndex("kotlin.coroutines.jvm.internal.BaseContinuationImpl")
74129
if (index == -1) return emptyList()
75130
return stackTrace.slice(0 until index)
76131
}
77132

78-
79133
@Suppress("NOTHING_TO_INLINE")
80134
internal actual suspend inline fun recoverAndThrow(exception: Throwable): Nothing {
81135
if (recoveryDisabled(exception)) throw exception
@@ -107,8 +161,8 @@ internal actual fun <E : Throwable> unwrap(exception: E): E {
107161
private fun <E : Throwable> recoveryDisabled(exception: E) =
108162
!RECOVER_STACKTRACE || !DEBUG || exception is CancellationException || exception is NonRecoverableThrowable
109163

110-
private fun createStackTrace(continuation: CoroutineStackFrame): ArrayList<StackTraceElement> {
111-
val stack = ArrayList<StackTraceElement>()
164+
private fun createStackTrace(continuation: CoroutineStackFrame): ArrayDeque<StackTraceElement> {
165+
val stack = ArrayDeque<StackTraceElement>()
112166
continuation.getStackTraceElement()?.let { stack.add(sanitize(it)) }
113167

114168
var last = continuation
@@ -128,6 +182,7 @@ internal fun sanitize(element: StackTraceElement): StackTraceElement {
128182
}
129183
internal fun artificialFrame(message: String) = java.lang.StackTraceElement("\b\b\b($message", "\b", "\b", -1)
130184
internal fun StackTraceElement.isArtificial() = className.startsWith("\b\b\b")
185+
private fun Array<StackTraceElement>.frameIndex(methodName: String) = indexOfFirst { methodName == it.className }
131186

132187
@Suppress("ACTUAL_WITHOUT_EXPECT")
133188
actual typealias CoroutineStackFrame = kotlin.coroutines.jvm.internal.CoroutineStackFrame
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
@file:Suppress("DeferredResultUnused", "DEPRECATION")
6+
7+
package kotlinx.coroutines.exceptions
8+
9+
import kotlinx.coroutines.*
10+
import kotlinx.coroutines.channels.*
11+
import org.junit.*
12+
import kotlin.coroutines.*
13+
14+
class StackTraceRecoveryNestedChannelsTest : TestBase() {
15+
16+
private val channel = Channel<Int>(0)
17+
18+
private suspend fun sendWithContext(ctx: CoroutineContext) = withContext(ctx) {
19+
sendInChannel()
20+
yield() // TCE
21+
}
22+
23+
private suspend fun sendInChannel() {
24+
channel.send(42)
25+
yield() // TCE
26+
}
27+
28+
private suspend fun sendFromScope() = coroutineScope {
29+
sendWithContext(wrapperDispatcher(coroutineContext))
30+
}
31+
32+
@Test
33+
fun testOfferWithCurrentContext() = runTest {
34+
channel.close(RecoverableTestException())
35+
36+
try {
37+
sendWithContext(coroutineContext)
38+
} catch (e: Exception) {
39+
verifyStackTrace(e,
40+
"kotlinx.coroutines.RecoverableTestException\n" +
41+
"\t(Current coroutine stacktrace)\n" +
42+
"\tat kotlinx.coroutines.channels.AbstractSendChannel.offer(AbstractChannel.kt:180)\n" +
43+
"\tat kotlinx.coroutines.channels.AbstractSendChannel.send(AbstractChannel.kt:168)\n" +
44+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest.sendInChannel(StackTraceRecoveryNestedChannelsTest.kt:24)\n" +
45+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$sendWithContext\$2.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:19)\n" +
46+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$sendWithContext\$2.invoke(StackTraceRecoveryNestedChannelsTest.kt)\n" +
47+
"\tat kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:84)\n" +
48+
"\tat kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:146)\n" +
49+
"\tat kotlinx.coroutines.BuildersKt.withContext(Unknown Source)\n" +
50+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest.sendWithContext(StackTraceRecoveryNestedChannelsTest.kt:18)\n" +
51+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testOfferWithCurrentContext\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:36)\n" +
52+
"Caused by: kotlinx.coroutines.RecoverableTestException\n" +
53+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testOfferWithCurrentContext\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:33)\n" +
54+
"\tat kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:32)")
55+
}
56+
}
57+
58+
@Test
59+
fun testOfferWithContextWrapped() = runTest {
60+
channel.close(RecoverableTestException())
61+
62+
try {
63+
sendWithContext(wrapperDispatcher(coroutineContext))
64+
} catch (e: Exception) {
65+
verifyStackTrace(e,
66+
"kotlinx.coroutines.RecoverableTestException\n" +
67+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testOfferWithContextWrapped\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:59)\n" +
68+
"\t(Current coroutine stacktrace)\n" +
69+
"\tat kotlinx.coroutines.channels.AbstractSendChannel.offer(AbstractChannel.kt:180)\n" +
70+
"\tat kotlinx.coroutines.channels.AbstractSendChannel.send(AbstractChannel.kt:168)\n" +
71+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest.sendInChannel(StackTraceRecoveryNestedChannelsTest.kt:24)\n" +
72+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$sendWithContext\$2.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:19)\n" +
73+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testOfferWithContextWrapped\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:62)\n" +
74+
"Caused by: kotlinx.coroutines.RecoverableTestException\n" +
75+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testOfferWithContextWrapped\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:59)\n" +
76+
"\tat kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:32)")
77+
}
78+
}
79+
80+
@Test
81+
fun testOfferFromScope() = runTest {
82+
channel.close(RecoverableTestException())
83+
84+
try {
85+
sendFromScope()
86+
} catch (e: Exception) {
87+
verifyStackTrace(e,
88+
"kotlinx.coroutines.RecoverableTestException\n" +
89+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testOfferFromScope\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:81)\n" +
90+
"\t(Current coroutine stacktrace)\n" +
91+
"\tat kotlinx.coroutines.channels.AbstractSendChannel.offer(AbstractChannel.kt:180)\n" +
92+
"\tat kotlinx.coroutines.channels.AbstractSendChannel.send(AbstractChannel.kt:168)\n" +
93+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest.sendInChannel(StackTraceRecoveryNestedChannelsTest.kt:24)\n" +
94+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$sendWithContext\$2.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:19)\n" +
95+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$sendFromScope\$2.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:28)\n" +
96+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testOfferFromScope\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:84)\n" +
97+
"Caused by: kotlinx.coroutines.RecoverableTestException\n" +
98+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testOfferFromScope\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:81)\n" +
99+
"\tat kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:32)")
100+
}
101+
}
102+
103+
// Slow path via suspending send
104+
@Test
105+
fun testSendFromScope() = runTest {
106+
val deferred = async {
107+
try {
108+
expect(1)
109+
sendFromScope()
110+
} catch (e: Exception) {
111+
verifyStackTrace(e,
112+
"kotlinx.coroutines.RecoverableTestException\n" +
113+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testSendFromScope\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:118)\n" +
114+
"\t(Current coroutine stacktrace)\n" +
115+
"\tat kotlinx.coroutines.channels.AbstractSendChannel.offer(AbstractChannel.kt:180)\n" +
116+
"\tat kotlinx.coroutines.channels.AbstractSendChannel.send(AbstractChannel.kt:168)\n" +
117+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest.sendInChannel(StackTraceRecoveryNestedChannelsTest.kt:24)\n" +
118+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$sendWithContext\$2.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:19)\n" +
119+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$sendFromScope\$2.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:29)\n" +
120+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testSendFromScope\$1\$deferred\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:109)\n" +
121+
"Caused by: kotlinx.coroutines.RecoverableTestException\n" +
122+
"\tat kotlinx.coroutines.exceptions.StackTraceRecoveryNestedChannelsTest\$testSendFromScope\$1.invokeSuspend(StackTraceRecoveryNestedChannelsTest.kt:118)\n" +
123+
"\tat kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:32)")
124+
}
125+
}
126+
127+
yield()
128+
expect(2)
129+
// Cancel is an analogue of `produce` failure, just a shorthand
130+
channel.cancel(RecoverableTestException())
131+
finish(3)
132+
deferred.await()
133+
}
134+
}

core/kotlinx-coroutines-core/test/exceptions/StackTraceRecoveryInHierarchiesTest.kt renamed to core/kotlinx-coroutines-core/test/exceptions/StackTraceRecoveryNestedTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import kotlinx.coroutines.*
1010
import org.junit.Test
1111
import kotlin.test.*
1212

13-
class StackTraceRecoveryInHierarchiesTest : TestBase() {
13+
class StackTraceRecoveryNestedTest : TestBase() {
1414

1515
@Test
1616
fun testNestedAsync() = runTest {

0 commit comments

Comments
 (0)