Skip to content

Commit b0f6e05

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. * Now exception is handled only once First part of fix for #689
1 parent 1b590e8 commit b0f6e05

File tree

11 files changed

+72
-54
lines changed

11 files changed

+72
-54
lines changed

binary-compatibility-validator/reference-public-api/kotlinx-coroutines-core.txt

+2-2
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ public class kotlinx/coroutines/JobSupport : kotlinx/coroutines/ChildJob, kotlin
382382
public fun get (Lkotlin/coroutines/CoroutineContext$Key;)Lkotlin/coroutines/CoroutineContext$Element;
383383
public final fun getCancellationException ()Ljava/util/concurrent/CancellationException;
384384
protected fun getCancelsParent ()Z
385-
public fun getChildJobCancellationCause ()Ljava/lang/Throwable;
385+
public fun getChildJobCancellationCause ()Ljava/util/concurrent/CancellationException;
386386
public final fun getChildren ()Lkotlin/sequences/Sequence;
387387
protected final fun getCompletionCause ()Ljava/lang/Throwable;
388388
protected final fun getCompletionCauseHandled ()Z
@@ -447,7 +447,7 @@ public abstract interface annotation class kotlinx/coroutines/ObsoleteCoroutines
447447
}
448448

449449
public abstract interface class kotlinx/coroutines/ParentJob : kotlinx/coroutines/Job {
450-
public abstract fun getChildJobCancellationCause ()Ljava/lang/Throwable;
450+
public abstract fun getChildJobCancellationCause ()Ljava/util/concurrent/CancellationException;
451451
}
452452

453453
public final class kotlinx/coroutines/ParentJob$DefaultImpls {

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?.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)