Skip to content

Commit c3a46d6

Browse files
committed
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 independable from source-code order Fixes #3714
1 parent d6f1403 commit c3a46d6

File tree

4 files changed

+53
-12
lines changed

4 files changed

+53
-12
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

+32-7
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,29 @@ 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 }
38+
* Try to reflectively find constructor(message, cause), constructor(message), constructor(cause), or constructor()
39+
* Exceptions are shared among coroutines, so we should copy exception before recovering current stacktrace.
40+
*
41+
* By default, Java's reflection iterates over ctors in the source-code order and the sorting is stable,
42+
* so in comparator we are picking the message ctor over the cause one to break such implcit dependency on the source code.
43+
*/
44+
val constructors = clz.constructors.sortedByDescending {
45+
// (m, c) -> 3
46+
// (m) -> 2
47+
// (c) -> 1
48+
// () -> 0
49+
val params = it.parameterTypes // cloned array
50+
when (params.size) {
51+
2 -> 3
52+
1 -> if (params[0] == String::class.java) 2 else 1
53+
0 -> 0
54+
else -> -1
55+
}
56+
57+
}
4258
for (constructor in constructors) {
4359
val result = createSafeConstructor(constructor)
4460
if (result != null) return result
@@ -66,8 +82,17 @@ private fun createSafeConstructor(constructor: Constructor<*>): Ctor? {
6682
}
6783
}
6884

69-
private inline fun safeCtor(crossinline block: (Throwable) -> Throwable): Ctor =
70-
{ e -> runCatching { block(e) }.getOrNull() }
85+
private inline fun safeCtor(crossinline block: (Throwable) -> Throwable): Ctor = { e ->
86+
runCatching {
87+
val result = block(e)
88+
/*
89+
* Verify that the new exception has the same message as the original one (bail out if not, see #1631)
90+
* or if the new message complies the contract from `Throwable(cause).message` contract.
91+
*/
92+
if (e.message != result.message && result.message != e.toString()) null
93+
else result
94+
}.getOrNull()
95+
}
7196

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

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

-4
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,6 @@ private fun <E : Throwable> recoverFromStackFrame(exception: E, continuation: Co
8484

8585
private fun <E : Throwable> tryCopyAndVerify(exception: E): E? {
8686
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
9087
return newException
9188
}
9289

@@ -157,7 +154,6 @@ private fun mergeRecoveredTraces(recoveredStacktrace: Array<StackTraceElement>,
157154
}
158155
}
159156

160-
@Suppress("NOTHING_TO_INLINE")
161157
internal actual suspend inline fun recoverAndThrow(exception: Throwable): Nothing {
162158
if (!RECOVER_STACK_TRACES) throw exception
163159
suspendCoroutineUninterceptedOrReturn<Nothing> {

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)