Skip to content

Commit 25a3553

Browse files
authored
Properly recover exceptions when they are constructed from 'Throwable… (#3731)
* Properly recover exceptions when they are constructed from 'Throwable(cause)' constructor. And restore the original behaviour. After #1631 this constructor's recovery mechanism was broken because 'Throwable(cause)' changes the message to be equal to 'cause.toString()', which isn't equal to the original message. Also, make reflective constructor choice undependable from source-code order Fixes #3714
1 parent 298419f commit 25a3553

File tree

6 files changed

+66
-51
lines changed

6 files changed

+66
-51
lines changed

.idea/codeStyles/Project.xml

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

kotlinx-coroutines-core/jvm/src/internal/ExceptionsConstructor.kt

+37-31
Original file line numberDiff line numberDiff line change
@@ -32,42 +32,48 @@ internal fun <E : Throwable> tryCopyException(exception: E): E? {
3232

3333
private fun <E : Throwable> createConstructor(clz: Class<E>): Ctor {
3434
val nullResult: Ctor = { null } // Pre-cache class
35-
// Skip reflective copy if an exception has additional fields (that are usually populated in user-defined constructors)
35+
// Skip reflective copy if an exception has additional fields (that are typically populated in user-defined constructors)
3636
if (throwableFields != clz.fieldsCountOrDefault(0)) return nullResult
3737
/*
38-
* Try to reflectively find constructor(), constructor(message, cause), constructor(cause) or constructor(message).
39-
* Exceptions are shared among coroutines, so we should copy exception before recovering current stacktrace.
40-
*/
41-
val constructors = clz.constructors.sortedByDescending { it.parameterTypes.size }
42-
for (constructor in constructors) {
43-
val result = createSafeConstructor(constructor)
44-
if (result != null) return result
45-
}
46-
return nullResult
47-
}
48-
49-
private fun createSafeConstructor(constructor: Constructor<*>): Ctor? {
50-
val p = constructor.parameterTypes
51-
return when (p.size) {
52-
2 -> when {
53-
p[0] == String::class.java && p[1] == Throwable::class.java ->
54-
safeCtor { e -> constructor.newInstance(e.message, e) as Throwable }
55-
else -> null
38+
* Try to reflectively find constructor(message, cause), constructor(message), constructor(cause), or constructor(),
39+
* in that order of priority.
40+
* Exceptions are shared among coroutines, so we should copy exception before recovering current stacktrace.
41+
*
42+
* By default, Java's reflection iterates over ctors in the source-code order and the sorting is stable, so we can
43+
* not rely on the order of iteration. Instead, we assign a unique priority to each ctor type.
44+
*/
45+
return clz.constructors.map { constructor ->
46+
val p = constructor.parameterTypes
47+
when (p.size) {
48+
2 -> when {
49+
p[0] == String::class.java && p[1] == Throwable::class.java ->
50+
safeCtor { e -> constructor.newInstance(e.message, e) as Throwable } to 3
51+
else -> null to -1
52+
}
53+
1 -> when (p[0]) {
54+
String::class.java ->
55+
safeCtor { e -> (constructor.newInstance(e.message) as Throwable).also { it.initCause(e) } } to 2
56+
Throwable::class.java ->
57+
safeCtor { e -> constructor.newInstance(e) as Throwable } to 1
58+
else -> null to -1
59+
}
60+
0 -> safeCtor { e -> (constructor.newInstance() as Throwable).also { it.initCause(e) } } to 0
61+
else -> null to -1
5662
}
57-
1 -> when (p[0]) {
58-
Throwable::class.java ->
59-
safeCtor { e -> constructor.newInstance(e) as Throwable }
60-
String::class.java ->
61-
safeCtor { e -> (constructor.newInstance(e.message) as Throwable).also { it.initCause(e) } }
62-
else -> null
63-
}
64-
0 -> safeCtor { e -> (constructor.newInstance() as Throwable).also { it.initCause(e) } }
65-
else -> null
66-
}
63+
}.maxByOrNull(Pair<*, Int>::second)?.first ?: nullResult
6764
}
6865

69-
private inline fun safeCtor(crossinline block: (Throwable) -> Throwable): Ctor =
70-
{ e -> runCatching { block(e) }.getOrNull() }
66+
private fun safeCtor(block: (Throwable) -> Throwable): Ctor = { e ->
67+
runCatching {
68+
val result = block(e)
69+
/*
70+
* Verify that the new exception has the same message as the original one (bail out if not, see #1631)
71+
* or if the new message complies the contract from `Throwable(cause).message` contract.
72+
*/
73+
if (e.message != result.message && result.message != e.toString()) null
74+
else result
75+
}.getOrNull()
76+
}
7177

7278
private fun Class<*>.fieldsCountOrDefault(defaultValue: Int) =
7379
kotlin.runCatching { fieldsCount() }.getOrDefault(defaultValue)

kotlinx-coroutines-core/jvm/src/internal/StackTraceRecovery.kt

+6-15
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,16 @@ private val stackTraceRecoveryClassName = runCatching {
3333
internal actual fun <E : Throwable> recoverStackTrace(exception: E): E {
3434
if (!RECOVER_STACK_TRACES) return exception
3535
// No unwrapping on continuation-less path: exception is not reported multiple times via slow paths
36-
val copy = tryCopyAndVerify(exception) ?: return exception
36+
val copy = tryCopyException(exception) ?: return exception
3737
return copy.sanitizeStackTrace()
3838
}
3939

4040
private fun <E : Throwable> E.sanitizeStackTrace(): E {
4141
val stackTrace = stackTrace
4242
val size = stackTrace.size
43-
val lastIntrinsic = stackTrace.frameIndex(stackTraceRecoveryClassName)
43+
val lastIntrinsic = stackTrace.indexOfLast { stackTraceRecoveryClassName == it.className }
4444
val startIndex = lastIntrinsic + 1
45-
val endIndex = stackTrace.frameIndex(baseContinuationImplClassName)
45+
val endIndex = stackTrace.firstFrameIndex(baseContinuationImplClassName)
4646
val adjustment = if (endIndex == -1) 0 else size - endIndex
4747
val trace = Array(size - lastIntrinsic - adjustment) {
4848
if (it == 0) {
@@ -70,7 +70,7 @@ private fun <E : Throwable> recoverFromStackFrame(exception: E, continuation: Co
7070
val (cause, recoveredStacktrace) = exception.causeAndStacktrace()
7171

7272
// Try to create an exception of the same type and get stacktrace from continuation
73-
val newException = tryCopyAndVerify(cause) ?: return exception
73+
val newException = tryCopyException(cause) ?: return exception
7474
// Update stacktrace
7575
val stacktrace = createStackTrace(continuation)
7676
if (stacktrace.isEmpty()) return exception
@@ -82,14 +82,6 @@ private fun <E : Throwable> recoverFromStackFrame(exception: E, continuation: Co
8282
return createFinalException(cause, newException, stacktrace)
8383
}
8484

85-
private fun <E : Throwable> tryCopyAndVerify(exception: E): E? {
86-
val newException = tryCopyException(exception) ?: return null
87-
// Verify that the new exception has the same message as the original one (bail out if not, see #1631)
88-
// CopyableThrowable has control over its message and thus can modify it the way it wants
89-
if (exception !is CopyableThrowable<*> && newException.message != exception.message) return null
90-
return newException
91-
}
92-
9385
/*
9486
* Here we partially copy original exception stackTrace to make current one much prettier.
9587
* E.g. for
@@ -109,7 +101,7 @@ private fun <E : Throwable> tryCopyAndVerify(exception: E): E? {
109101
private fun <E : Throwable> createFinalException(cause: E, result: E, resultStackTrace: ArrayDeque<StackTraceElement>): E {
110102
resultStackTrace.addFirst(ARTIFICIAL_FRAME)
111103
val causeTrace = cause.stackTrace
112-
val size = causeTrace.frameIndex(baseContinuationImplClassName)
104+
val size = causeTrace.firstFrameIndex(baseContinuationImplClassName)
113105
if (size == -1) {
114106
result.stackTrace = resultStackTrace.toTypedArray()
115107
return result
@@ -157,7 +149,6 @@ private fun mergeRecoveredTraces(recoveredStacktrace: Array<StackTraceElement>,
157149
}
158150
}
159151

160-
@Suppress("NOTHING_TO_INLINE")
161152
internal actual suspend inline fun recoverAndThrow(exception: Throwable): Nothing {
162153
if (!RECOVER_STACK_TRACES) throw exception
163154
suspendCoroutineUninterceptedOrReturn<Nothing> {
@@ -198,7 +189,7 @@ private fun createStackTrace(continuation: CoroutineStackFrame): ArrayDeque<Stac
198189
}
199190

200191
internal fun StackTraceElement.isArtificial() = className.startsWith(ARTIFICIAL_FRAME_PACKAGE_NAME)
201-
private fun Array<StackTraceElement>.frameIndex(methodName: String) = indexOfFirst { methodName == it.className }
192+
private fun Array<StackTraceElement>.firstFrameIndex(methodName: String) = indexOfFirst { methodName == it.className }
202193

203194
private fun StackTraceElement.elementWiseEquals(e: StackTraceElement): Boolean {
204195
/*
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
kotlinx.coroutines.RecoverableTestException
2-
at kotlinx.coroutines.internal.StackTraceRecoveryKt.recoverStackTrace(StackTraceRecovery.kt)
32
at kotlinx.coroutines.channels.BufferedChannel.receive$suspendImpl(BufferedChannel.kt)
43
at kotlinx.coroutines.channels.BufferedChannel.receive(BufferedChannel.kt)
54
at kotlinx.coroutines.exceptions.StackTraceRecoveryChannelsTest.channelReceive(StackTraceRecoveryChannelsTest.kt)
65
at kotlinx.coroutines.exceptions.StackTraceRecoveryChannelsTest.access$channelReceive(StackTraceRecoveryChannelsTest.kt)
76
at kotlinx.coroutines.exceptions.StackTraceRecoveryChannelsTest$channelReceive$1.invokeSuspend(StackTraceRecoveryChannelsTest.kt)
87
Caused by: kotlinx.coroutines.RecoverableTestException
98
at kotlinx.coroutines.exceptions.StackTraceRecoveryChannelsTest$testReceiveFromChannel$1$job$1.invokeSuspend(StackTraceRecoveryChannelsTest.kt)
10-
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt)
9+
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt)
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
kotlinx.coroutines.RecoverableTestException
2-
at kotlinx.coroutines.internal.StackTraceRecoveryKt.recoverStackTrace(StackTraceRecovery.kt)
32
at kotlinx.coroutines.channels.BufferedChannel.receive$suspendImpl(BufferedChannel.kt)
43
at kotlinx.coroutines.channels.BufferedChannel.receive(BufferedChannel.kt)
54
at kotlinx.coroutines.exceptions.StackTraceRecoveryResumeModeTest$withContext$2.invokeSuspend(StackTraceRecoveryResumeModeTest.kt)
65
Caused by: kotlinx.coroutines.RecoverableTestException
76
at kotlinx.coroutines.exceptions.StackTraceRecoveryResumeModeTest.testResumeModeFastPath(StackTraceRecoveryResumeModeTest.kt)
87
at kotlinx.coroutines.exceptions.StackTraceRecoveryResumeModeTest.access$testResumeModeFastPath(StackTraceRecoveryResumeModeTest.kt)
98
at kotlinx.coroutines.exceptions.StackTraceRecoveryResumeModeTest$testUnconfined$1.invokeSuspend(StackTraceRecoveryResumeModeTest.kt)
10-
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt)
9+
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt)

kotlinx-coroutines-core/jvm/test/exceptions/StackTraceRecoveryCustomExceptionsTest.kt

+20
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,26 @@ class StackTraceRecoveryCustomExceptionsTest : TestBase() {
8787
assertEquals("Token OK", ex.message)
8888
}
8989

90+
@Test
91+
fun testNestedExceptionWithCause() = runTest {
92+
val result = runCatching {
93+
coroutineScope<Unit> {
94+
throw NestedException(IllegalStateException("ERROR"))
95+
}
96+
}
97+
val ex = result.exceptionOrNull() ?: error("Expected to fail")
98+
assertIs<NestedException>(ex)
99+
assertIs<NestedException>(ex.cause)
100+
val originalCause = ex.cause?.cause
101+
assertIs<IllegalStateException>(originalCause)
102+
assertEquals("ERROR", originalCause.message)
103+
}
104+
105+
class NestedException : RuntimeException {
106+
constructor(cause: Throwable) : super(cause)
107+
constructor() : super()
108+
}
109+
90110
@Test
91111
fun testWrongMessageExceptionInChannel() = runTest {
92112
val result = produce<Unit>(SupervisorJob() + Dispatchers.Unconfined) {

0 commit comments

Comments
 (0)