Skip to content

Commit d3fd575

Browse files
committed
Improve coroutine exception handling logic
* Wrap exceptions into CE if parent cancels its child. This is possible because now we can be sure that no one is invoking 'cancel(Throwable)', thus if parent cancels its children, an exception is definitely handled. * This changes allows exceptions in hierarchies to be handled only once even when coroutines in the hierarchy are capable of handling exceptions * Breaking change: CompletableJob.completeExceptionally(exception) uses its parameter as additional debug information for children ('cause' of CE), thus changing the result of its children. For example, if CJ was a parent of CompleteableDeferred and CJ was completed exceptionally with exception E1, now CD.await() will throw CE instead of E1 First part of fix for #689
1 parent 218dc97 commit d3fd575

10 files changed

+70
-52
lines changed

kotlinx-coroutines-core/common/src/CompletableJob.kt

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public interface CompletableJob : Job {
2424
/**
2525
* Completes this job exceptionally with a given [exception]. The result is `true` if this job was
2626
* completed as a result of this invocation and `false` otherwise (if it was already completed).
27+
* [exception] parameter is used as an additional debug information that is not handled by any exception handlers.
2728
*
2829
* Subsequent invocations of this function have no effect and always produce `false`.
2930
*

kotlinx-coroutines-core/common/src/Job.kt

+4-1
Original file line numberDiff line numberDiff line change
@@ -422,10 +422,13 @@ public interface ParentJob : Job {
422422
* Child job is using this method to learn its cancellation cause when the parent cancels it with [ChildJob.parentCancelled].
423423
* This method is invoked only if the child was not already being cancelled.
424424
*
425+
* Note that [CancellationException] is the method's return type: if child is cancelled by its parent,
426+
* then the original exception is **already** handled by either the parent or the original source of failure.
427+
*
425428
* @suppress **This is unstable API and it is subject to change.**
426429
*/
427430
@InternalCoroutinesApi
428-
public fun getChildJobCancellationCause(): Throwable
431+
public fun getChildJobCancellationCause(): CancellationException
429432
}
430433

431434
/**

kotlinx-coroutines-core/common/src/JobSupport.kt

+5-14
Original file line numberDiff line numberDiff line change
@@ -635,29 +635,20 @@ public open class JobSupport constructor(active: Boolean) : Job, ChildJob, Paren
635635
private fun createJobCancellationException() =
636636
JobCancellationException("Job was cancelled", null, this)
637637

638-
override fun getChildJobCancellationCause(): Throwable {
638+
override fun getChildJobCancellationCause(): CancellationException {
639639
// determine root cancellation cause of this job (why is it cancelling its children?)
640640
val state = this.state
641641
val rootCause = when (state) {
642-
is Finishing -> state.rootCause
642+
is Finishing -> state.rootCause as? CancellationException ?: JobCancellationException("Parent job was cancelled", state.rootCause, this)
643+
is CompletedExceptionally -> state.cause as? CancellationException ?: JobCancellationException("Parent job was cancelled", state.cause, this)
643644
is Incomplete -> error("Cannot be cancelling child in this state: $state")
644-
is CompletedExceptionally -> state.cause
645645
else -> null // create exception with the below code on normal completion
646646
}
647-
/*
648-
* If this parent job handles exceptions, then wrap cause into JobCancellationException, because we
649-
* don't want the child to handle this exception on more time. Otherwise, pass our original rootCause
650-
* to the child for cancellation.
651-
*/
652-
return if (rootCause == null || handlesException && rootCause !is CancellationException) {
653-
JobCancellationException("Parent job is ${stateString(state)}", rootCause, this)
654-
} else {
655-
rootCause
656-
}
647+
return rootCause ?: JobCancellationException("Parent job is ${stateString(state)}", rootCause, this)
657648
}
658649

659650
// cause is Throwable or ParentJob when cancelChild was invoked
660-
private fun createCauseException(cause: Any?): Throwable = when(cause) {
651+
private fun createCauseException(cause: Any?): Throwable = when (cause) {
661652
is Throwable? -> cause ?: createJobCancellationException()
662653
else -> (cause as ParentJob).getChildJobCancellationCause()
663654
}

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ class SupervisorTest : TestBase() {
171171
try {
172172
deferred.await()
173173
expectUnreached()
174-
} catch (e: TestException1) {
174+
} catch (e: CancellationException) {
175+
assertTrue(e.cause is TestException1)
175176
finish(3)
176177
}
177178
}

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class CapturingHandler : AbstractCoroutineContextElement(CoroutineExceptionHandl
5454
}
5555
}
5656

57-
internal fun runBlock(
57+
internal fun captureExceptionsRun(
5858
context: CoroutineContext = EmptyCoroutineContext,
5959
block: suspend CoroutineScope.() -> Unit
6060
): Throwable {
@@ -63,7 +63,7 @@ internal fun runBlock(
6363
return handler.getException()
6464
}
6565

66-
internal fun runBlockForMultipleExceptions(
66+
internal fun captureMultipleExceptionsRun(
6767
context: CoroutineContext = EmptyCoroutineContext,
6868
block: suspend CoroutineScope.() -> Unit
6969
): List<Throwable> {

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

+48-26
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ class JobExceptionHandlingTest : TestBase() {
1818
/*
1919
* Root parent: JobImpl()
2020
* Child: throws ISE
21-
* Result: ISE in exception handler
21+
* Result: ISE in exception handlerWithContextExceptionHandlingTest
2222
*/
23-
val exception = runBlock {
23+
val exception = captureExceptionsRun {
2424
val job = Job()
2525
launch(job, start = ATOMIC) {
2626
expect(2)
@@ -36,43 +36,39 @@ class JobExceptionHandlingTest : TestBase() {
3636
}
3737

3838
@Test
39-
fun testAsyncCancellationWithCause() = runTest {
40-
val deferred = async(NonCancellable) {
39+
fun testAsyncCancellationWithCauseAndParent() = runTest {
40+
val parent = Job()
41+
val deferred = async(parent) {
4142
expect(2)
4243
delay(Long.MAX_VALUE)
4344
}
4445

4546
expect(1)
4647
yield()
47-
deferred.cancel(TestCancellationException("TEST"))
48+
parent.completeExceptionally(IOException())
4849
try {
4950
deferred.await()
5051
expectUnreached()
51-
} catch (e: TestCancellationException) {
52-
assertEquals("TEST", e.message)
52+
} catch (e: CancellationException) {
5353
assertTrue(e.suppressed.isEmpty())
54+
assertTrue(e.cause?.suppressed?.isEmpty() ?: false)
5455
finish(3)
5556
}
5657
}
5758

5859
@Test
59-
fun testAsyncCancellationWithCauseAndParent() = runTest {
60+
fun testAsyncCancellationWithCauseAndParentDoesNotTriggerHandling() = runTest {
6061
val parent = Job()
61-
val deferred = async(parent) {
62+
val job = launch(parent) {
6263
expect(2)
6364
delay(Long.MAX_VALUE)
6465
}
6566

6667
expect(1)
6768
yield()
6869
parent.completeExceptionally(IOException())
69-
try {
70-
deferred.await()
71-
expectUnreached()
72-
} catch (e: IOException) {
73-
assertTrue(e.suppressed.isEmpty())
74-
finish(3)
75-
}
70+
job.join()
71+
finish(3)
7672
}
7773

7874
@Test
@@ -85,7 +81,7 @@ class JobExceptionHandlingTest : TestBase() {
8581
*
8682
* Github issue #354
8783
*/
88-
val exception = runBlock {
84+
val exception = captureExceptionsRun {
8985
val job = Job()
9086
val child = launch(job, start = ATOMIC) {
9187
expect(2)
@@ -109,7 +105,7 @@ class JobExceptionHandlingTest : TestBase() {
109105
* Inner child: throws AE
110106
* Result: AE in exception handler
111107
*/
112-
val exception = runBlock {
108+
val exception = captureExceptionsRun {
113109
val job = Job()
114110
launch(job) {
115111
expect(2) // <- child is launched successfully
@@ -145,7 +141,7 @@ class JobExceptionHandlingTest : TestBase() {
145141
* Inner child: throws AE
146142
* Result: AE
147143
*/
148-
val exception = runBlock {
144+
val exception = captureExceptionsRun {
149145
val job = Job()
150146
launch(job, start = ATOMIC) {
151147
expect(2)
@@ -173,7 +169,7 @@ class JobExceptionHandlingTest : TestBase() {
173169
* Inner child: throws AE
174170
* Result: IOE with suppressed AE
175171
*/
176-
val exception = runBlock {
172+
val exception = captureExceptionsRun {
177173
val job = Job()
178174
launch(job) {
179175
expect(2) // <- child is launched successfully
@@ -196,11 +192,9 @@ class JobExceptionHandlingTest : TestBase() {
196192
finish(5)
197193
}
198194

199-
assertTrue(exception is IOException)
195+
assertTrue(exception is ArithmeticException)
200196
assertNull(exception.cause)
201-
val suppressed = exception.suppressed
202-
assertEquals(1, suppressed.size)
203-
checkException<ArithmeticException>(suppressed[0])
197+
assertTrue(exception.suppressed.isEmpty())
204198
}
205199

206200
@Test
@@ -211,7 +205,7 @@ class JobExceptionHandlingTest : TestBase() {
211205
* Child: launch 3 children, each of them throws an exception (AE, IOE, IAE) and calls delay()
212206
* Result: AE with suppressed IOE and IAE
213207
*/
214-
val exception = runBlock {
208+
val exception = captureExceptionsRun {
215209
val job = Job()
216210
launch(job, start = ATOMIC) {
217211
expect(2)
@@ -253,7 +247,7 @@ class JobExceptionHandlingTest : TestBase() {
253247
* Child: launch 2 children (each of them throws an exception (IOE, IAE)), throws AE
254248
* Result: AE with suppressed IOE and IAE
255249
*/
256-
val exception = runBlock {
250+
val exception = captureExceptionsRun {
257251
val job = Job()
258252
launch(job, start = ATOMIC) {
259253
expect(2)
@@ -282,6 +276,34 @@ class JobExceptionHandlingTest : TestBase() {
282276
assertTrue(suppressed[1] is IllegalArgumentException)
283277
}
284278

279+
@Test
280+
fun testExceptionIsHandledOnce() = runTest(unhandled = listOf { e -> e is TestException }) {
281+
val job = Job()
282+
val j1 = launch(job) {
283+
expect(1)
284+
delay(Long.MAX_VALUE)
285+
}
286+
287+
val j2 = launch(job) {
288+
expect(2)
289+
throw TestException()
290+
}
291+
292+
joinAll(j1 ,j2)
293+
finish(3)
294+
}
295+
296+
@Test
297+
fun testCancelledParent() = runTest {
298+
expect(1)
299+
val parent = Job()
300+
parent.completeExceptionally(TestException())
301+
launch(parent) {
302+
expectUnreached()
303+
}.join()
304+
finish(2)
305+
}
306+
285307
@Test
286308
fun testBadException() = runTest(unhandled = listOf({e -> e is BadException})) {
287309
val job = launch(Job()) {

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

+1-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ package kotlinx.coroutines.exceptions
77
import kotlinx.coroutines.*
88
import org.junit.*
99
import org.junit.Test
10-
import java.io.*
1110
import java.util.concurrent.*
1211
import kotlin.test.*
1312

@@ -28,7 +27,7 @@ class JobExceptionsStressTest : TestBase() {
2827
* Result: one of the exceptions with the rest two as suppressed
2928
*/
3029
repeat(1000 * stressTestMultiplier) {
31-
val exception = runBlock(executor) {
30+
val exception = captureExceptionsRun(executor) {
3231
val barrier = CyclicBarrier(4)
3332
val job = launch(NonCancellable) {
3433
launch(start = CoroutineStart.ATOMIC) {

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

+4-4
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class JobNestedExceptionsTest : TestBase() {
1313

1414
@Test
1515
fun testExceptionUnwrapping() {
16-
val exception = runBlock {
16+
val exception = captureExceptionsRun {
1717
val job = Job()
1818
launch(job) {
1919
expect(2)
@@ -37,7 +37,7 @@ class JobNestedExceptionsTest : TestBase() {
3737

3838
@Test
3939
fun testExceptionUnwrappingWithSuspensions() {
40-
val exception = runBlock {
40+
val exception = captureExceptionsRun {
4141
val job = Job()
4242
launch(job) {
4343
expect(2)
@@ -66,7 +66,7 @@ class JobNestedExceptionsTest : TestBase() {
6666

6767
@Test
6868
fun testNestedAtomicThrow() {
69-
val exception = runBlock {
69+
val exception = captureExceptionsRun {
7070
expect(1)
7171
val job = launch(NonCancellable + CoroutineName("outer"), start = CoroutineStart.ATOMIC) {
7272
expect(2)
@@ -86,7 +86,7 @@ class JobNestedExceptionsTest : TestBase() {
8686

8787
@Test
8888
fun testChildThrowsDuringCompletion() {
89-
val exceptions = runBlockForMultipleExceptions {
89+
val exceptions = captureMultipleExceptionsRun {
9090
expect(1)
9191
val job = launch(NonCancellable + CoroutineName("outer"), start = CoroutineStart.ATOMIC) {
9292
expect(2)

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,9 @@ class ProduceExceptionsTest : TestBase() {
160160
yield()
161161
try {
162162
channel.receive()
163-
} catch (e: TestException2) {
163+
} catch (e: CancellationException) {
164+
// RECOVER_STACK_TRACES
165+
assertTrue(e.cause?.cause is TestException2)
164166
finish(4)
165167
}
166168
}

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

-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import org.junit.runners.*
1111
import kotlin.coroutines.*
1212
import kotlin.test.*
1313

14-
@Suppress("DEPRECATION") // cancel(cause)
1514
@RunWith(Parameterized::class)
1615
class WithContextExceptionHandlingTest(private val mode: Mode) : TestBase() {
1716
enum class Mode { WITH_CONTEXT, ASYNC_AWAIT }

0 commit comments

Comments
 (0)