Skip to content

Commit 6aed878

Browse files
committed
Exception propagation logic from cancelled coroutines is adjusted
* When cancelled coroutine crashes due to some other exception, this other exception becomes the cancellation reason of the coroutine, while the original cancellation reason is suppressed. * UnexpectedCoroutineException is no longer used to report those cases as is removed. * This fixes a race between crash of CPU-consuming coroutine and cancellation which resulted in an unhandled exception and lead to crashes on Android. Fixes #152
1 parent ff850b0 commit 6aed878

File tree

5 files changed

+90
-50
lines changed

5 files changed

+90
-50
lines changed

core/kotlinx-coroutines-core/src/main/kotlin/kotlinx/coroutines/experimental/Job.kt

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -376,13 +376,6 @@ public class JobCancellationException(
376376
(message!!.hashCode() * 31 + job.hashCode()) * 31 + (cause?.hashCode() ?: 0)
377377
}
378378

379-
/**
380-
* Represents an exception in the coroutine that was not caught by it and was not expected to be thrown.
381-
* This happens when coroutine is cancelled, but it completes with the different exception than its cancellation
382-
* cause was.
383-
*/
384-
public class UnexpectedCoroutineException(message: String, cause: Throwable) : IllegalStateException(message, cause)
385-
386379
/**
387380
* Unregisters a specified [registration] when this job is complete.
388381
*
@@ -621,38 +614,31 @@ public open class JobSupport(active: Boolean) : Job, SelectClause0, SelectClause
621614
val update = coerceProposedUpdate(expect, proposedUpdate)
622615
if (!tryUpdateState(expect, update)) return false
623616
completeUpdateState(expect, update, mode)
624-
// if an exceptional completion was suppressed (because cancellation was in progress), then report it separately
625-
if (proposedUpdate is CompletedExceptionally && proposedUpdate.cause != null && !incorporatedCause(update, proposedUpdate.cause)) {
626-
handleException(UnexpectedCoroutineException("Unexpected exception while cancellation is in progress; job=$this", proposedUpdate.cause))
627-
}
628617
return true
629618
}
630619

631-
/**
632-
* Checks if the cause that was proposed for state update is consistent with the resulting updated state
633-
* and not exception information was lost. The key observation here is that [getCancellationException] wraps
634-
* exceptions that are not [CancellationException] into an instance of [JobCancellationException] and we allow
635-
* that [JobCancellationException] to be unwrapped again when it reaches the coroutine that was cancelled.
636-
*
637-
* NOTE: equality comparison of exceptions is performed here by design, see equals of JobCancellationException
638-
*/
639-
private fun incorporatedCause(update: Any?, proposedCause: Throwable) =
640-
update is CompletedExceptionally && update.exception.let { ex ->
641-
ex == proposedCause || proposedCause is JobCancellationException && ex == proposedCause.cause
642-
}
643-
644-
// when Job is in Cancelling state, it can only be promoted to Cancelled state with the same cause
645-
// however, null cause can be replaced with more specific JobCancellationException (that contains better stack trace)
620+
// when Job is in Cancelling state, it can only be promoted to Cancelled state,
621+
// so if the proposed Update is not an appropriate Cancelled (preserving the cancellation cause),
622+
// then the corresponding Cancelled state is constructed.
646623
private fun coerceProposedUpdate(expect: Incomplete, proposedUpdate: Any?): Any? =
647-
if (expect is Finishing && expect.cancelled != null && !correspondinglyCancelled(expect.cancelled, proposedUpdate))
648-
expect.cancelled else proposedUpdate
624+
if (expect is Finishing && expect.cancelled != null && !isCorrespondinglyCancelled(expect.cancelled, proposedUpdate))
625+
createCancelled(expect.cancelled, proposedUpdate) else proposedUpdate
649626

650-
private fun correspondinglyCancelled(cancelled: Cancelled, proposedUpdate: Any?): Boolean {
627+
private fun isCorrespondinglyCancelled(cancelled: Cancelled, proposedUpdate: Any?): Boolean {
651628
if (proposedUpdate !is Cancelled) return false
652-
return proposedUpdate.cause === cancelled.cause ||
629+
// NOTE: equality comparison of causes is performed here by design, see equals of JobCancellationException
630+
return proposedUpdate.cause == cancelled.cause ||
653631
proposedUpdate.cause is JobCancellationException && cancelled.cause == null
654632
}
655633

634+
private fun createCancelled(cancelled: Cancelled, proposedUpdate: Any?): Cancelled {
635+
if (proposedUpdate !is CompletedExceptionally) return cancelled // not exception -- just use original cancelled
636+
val exception = proposedUpdate.exception
637+
if (cancelled.exception == exception) return cancelled // that is the cancelled we need already!
638+
cancelled.cause?.let { exception.addSuppressed(it) }
639+
return Cancelled(this, exception)
640+
}
641+
656642
/**
657643
* Tries to initiate update of the current [state] of this job.
658644
* @suppress **This is unstable API and it is subject to change.**

core/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/CoroutinesTest.kt

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,14 @@ class CoroutinesTest : TestBase() {
162162

163163
@Test
164164
fun testCancelParentOnChildException() = runTest(
165-
expected = { it is IOException },
165+
expected = { it is JobCancellationException && it.cause is IOException },
166166
unhandled = listOf({ it -> it is IOException })
167167
) {
168168
expect(1)
169169
launch(coroutineContext) {
170170
finish(3)
171-
throw IOException() // does not propagate exception to launch, but cancels parent (!)
171+
throwIOException() // does not propagate exception to launch, but cancels parent (!)
172+
expectUnreached()
172173
}
173174
expect(2)
174175
yield()
@@ -177,7 +178,7 @@ class CoroutinesTest : TestBase() {
177178

178179
@Test
179180
fun testCancelParentOnNestedException() = runTest(
180-
expected = { it is IOException },
181+
expected = { it is JobCancellationException && it.cause is IOException },
181182
unhandled = listOf(
182183
{ it -> it is IOException },
183184
{ it -> it is IOException }
@@ -188,7 +189,8 @@ class CoroutinesTest : TestBase() {
188189
expect(3)
189190
launch(coroutineContext) {
190191
finish(6)
191-
throw IOException() // unhandled exception kills all parents
192+
throwIOException() // unhandled exception kills all parents
193+
expectUnreached()
192194
}
193195
expect(4)
194196
yield()
@@ -246,13 +248,14 @@ class CoroutinesTest : TestBase() {
246248

247249
@Test
248250
fun testCancelAndJoinChildCrash() = runTest(
249-
expected = { it is IOException && it.message == "OK" },
251+
expected = { it is JobCancellationException && it.cause is IOException },
250252
unhandled = listOf({it -> it is IOException })
251253
) {
252254
expect(1)
253255
val job = launch(coroutineContext, CoroutineStart.UNDISPATCHED) {
254256
expect(2)
255-
throw IOException("OK")
257+
throwIOException()
258+
expectUnreached()
256259
}
257260
// now we have a failed job with IOException
258261
finish(3)
@@ -310,5 +313,38 @@ class CoroutinesTest : TestBase() {
310313
expectUnreached()
311314
}
312315

316+
@Test
317+
fun testNotCancellableCodeWithExceptionCancelled() = runTest {
318+
expect(1)
319+
// CoroutineStart.ATOMIC makes sure it will not get cancelled for it starts executing
320+
val job = launch(start = CoroutineStart.ATOMIC) {
321+
Thread.sleep(100) // cannot be cancelled
322+
throwIOException() // will throw
323+
expectUnreached()
324+
}
325+
expect(2)
326+
job.cancel()
327+
finish(3)
328+
}
329+
330+
@Test
331+
fun testNotCancellableChildWithExceptionCancelled() = runTest(
332+
expected = { it is IOException }
333+
) {
334+
expect(1)
335+
// CoroutineStart.ATOMIC makes sure it will not get cancelled for it starts executing
336+
val d = async(coroutineContext, start = CoroutineStart.ATOMIC) {
337+
finish(4)
338+
throwIOException() // will throw
339+
expectUnreached()
340+
}
341+
expect(2)
342+
// now cancel with some other exception
343+
d.cancel(IllegalArgumentException())
344+
// now await to see how it got crashed -- IAE should have been suppressed by IOException
345+
expect(3)
346+
d.await()
347+
}
348+
313349
private fun throwIOException() { throw IOException() }
314350
}

core/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/WithTimeoutOrNullTest.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ class WithTimeoutOrNullTest : TestBase() {
9292
}
9393

9494
@Test
95-
fun testSuppressException() = runTest {
95+
fun testSuppressExceptionWithResult() = runTest {
9696
expect(1)
9797
val result = withTimeoutOrNull(100) {
9898
expect(2)
@@ -108,23 +108,22 @@ class WithTimeoutOrNullTest : TestBase() {
108108
}
109109

110110
@Test
111-
fun testReplaceException() = runTest(
112-
unhandled = listOf({ it -> it is UnexpectedCoroutineException && it.cause is IOException })
111+
fun testSuppressExceptionWithAnotherException() = runTest(
112+
expected = { it is IOException }
113113
) {
114114
expect(1)
115115
val result = withTimeoutOrNull(100) {
116116
expect(2)
117117
try {
118118
delay(1000)
119119
} catch (e: CancellationException) {
120-
expect(3)
120+
finish(3)
121121
throw IOException(e)
122122
}
123123
expectUnreached()
124124
"OK"
125125
}
126-
assertThat(result, IsNull())
127-
finish(4)
126+
expectUnreached()
128127
}
129128

130129
/**

core/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/WithTimeoutTest.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ class WithTimeoutTest : TestBase() {
9595
}
9696

9797
@Test
98-
fun testSuppressException() = runTest(
98+
fun testSuppressExceptionWithResult() = runTest(
9999
expected = { it is CancellationException }
100100
) {
101101
expect(1)
@@ -112,9 +112,8 @@ class WithTimeoutTest : TestBase() {
112112
}
113113

114114
@Test
115-
fun testReplaceException() = runTest(
116-
expected = { it is CancellationException },
117-
unhandled = listOf({ it -> it is UnexpectedCoroutineException && it.cause is IOException })
115+
fun testSuppressExceptionWithAnotherException() = runTest(
116+
expected = { it is IOException }
118117
) {
119118
expect(1)
120119
withTimeout(100) {

core/kotlinx-coroutines-core/src/test/kotlin/kotlinx/coroutines/experimental/channels/ProduceTest.kt

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,51 @@
1616

1717
package kotlinx.coroutines.experimental.channels
1818

19+
import kotlinx.coroutines.experimental.Job
20+
import kotlinx.coroutines.experimental.JobCancellationException
1921
import kotlinx.coroutines.experimental.TestBase
20-
import kotlinx.coroutines.experimental.runBlocking
2122
import org.junit.Test
2223

2324
class ProduceTest : TestBase() {
2425
@Test
25-
fun testBasic() = runBlocking {
26+
fun testBasic() = runTest {
2627
val c = produce(coroutineContext) {
28+
expect(2)
2729
send(1)
30+
expect(3)
2831
send(2)
32+
expect(6)
2933
}
34+
expect(1)
3035
check(c.receive() == 1)
36+
expect(4)
3137
check(c.receive() == 2)
38+
expect(5)
3239
check(c.receiveOrNull() == null)
40+
finish(7)
3341
}
3442

3543
@Test
36-
fun testCancel() = runBlocking {
44+
fun testCancel() = runTest {
3745
val c = produce(coroutineContext) {
46+
expect(2)
3847
send(1)
39-
send(2)
48+
expect(3)
49+
try {
50+
send(2) // will get cancelled
51+
} catch (e: Throwable) {
52+
expect(6)
53+
check(e is JobCancellationException && e.job == coroutineContext[Job])
54+
throw e
55+
}
4056
expectUnreached()
4157
}
58+
expect(1)
4259
check(c.receive() == 1)
60+
expect(4)
4361
c.cancel()
62+
expect(5)
4463
check(c.receiveOrNull() == null)
64+
finish(7)
4565
}
4666
}

0 commit comments

Comments
 (0)