Skip to content

Commit 715a0d4

Browse files
authored
Do not throw from JobListenableFuture.isCancelled (#2498)
* Fix toString representation of JobListenableFuture * Do not throw from JobListenableFuture.isCancelled. This properly handles ExecutionException that can be thrown from getUninterruptibly. Co-authored-by: Vadim Semenov <[email protected]> Fixes #2421
1 parent 76b12f9 commit 715a0d4

File tree

3 files changed

+141
-5
lines changed

3 files changed

+141
-5
lines changed

integration/kotlinx-coroutines-guava/src/ListenableFuture.kt

+28-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2016-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
33
*/
44

55
package kotlinx.coroutines.guava
@@ -302,7 +302,8 @@ private class ListenableFutureCoroutine<T>(
302302
) : AbstractCoroutine<T>(context) {
303303

304304
// JobListenableFuture propagates external cancellation to `this` coroutine. See JobListenableFuture.
305-
@JvmField val future = JobListenableFuture<T>(this)
305+
@JvmField
306+
val future = JobListenableFuture<T>(this)
306307

307308
override fun onCompleted(value: T) {
308309
future.complete(value)
@@ -347,6 +348,17 @@ private class JobListenableFuture<T>(private val jobToCancel: Job): ListenableFu
347348
*/
348349
private val auxFuture = SettableFuture.create<Any>()
349350

351+
/**
352+
* `true` if [auxFuture.get][ListenableFuture.get] throws [ExecutionException].
353+
*
354+
* Note: this is eventually consistent with the state of [auxFuture].
355+
*
356+
* Unfortunately, there's no API to figure out if [ListenableFuture] throws [ExecutionException]
357+
* apart from calling [ListenableFuture.get] on it. To avoid unnecessary [ExecutionException] allocation
358+
* we use this field as an optimization.
359+
*/
360+
private var auxFutureIsFailed: Boolean = false
361+
350362
/**
351363
* When the attached coroutine [isCompleted][Job.isCompleted] successfully
352364
* its outcome should be passed to this method.
@@ -366,7 +378,8 @@ private class JobListenableFuture<T>(private val jobToCancel: Job): ListenableFu
366378
// CancellationException is wrapped into `Cancelled` to preserve original cause and message.
367379
// All the other exceptions are delegated to SettableFuture.setException.
368380
fun completeExceptionallyOrCancel(t: Throwable): Boolean =
369-
if (t is CancellationException) auxFuture.set(Cancelled(t)) else auxFuture.setException(t)
381+
if (t is CancellationException) auxFuture.set(Cancelled(t))
382+
else auxFuture.setException(t).also { if (it) auxFutureIsFailed = true }
370383

371384
/**
372385
* Returns cancellation _in the sense of [Future]_. This is _not_ equivalent to
@@ -385,7 +398,16 @@ private class JobListenableFuture<T>(private val jobToCancel: Job): ListenableFu
385398
// this Future hasn't itself been successfully cancelled, the Future will return
386399
// isCancelled() == false. This is the only discovered way to reconcile the two different
387400
// cancellation contracts.
388-
return auxFuture.isCancelled || (isDone && Uninterruptibles.getUninterruptibly(auxFuture) is Cancelled)
401+
return auxFuture.isCancelled || isDone && !auxFutureIsFailed && try {
402+
Uninterruptibles.getUninterruptibly(auxFuture) is Cancelled
403+
} catch (e: CancellationException) {
404+
// `auxFuture` got cancelled right after `auxFuture.isCancelled` returned false.
405+
true
406+
} catch (e: ExecutionException) {
407+
// `auxFutureIsFailed` hasn't been updated yet.
408+
auxFutureIsFailed = true
409+
false
410+
}
389411
}
390412

391413
/**
@@ -455,7 +477,7 @@ private class JobListenableFuture<T>(private val jobToCancel: Job): ListenableFu
455477
try {
456478
when (val result = Uninterruptibles.getUninterruptibly(auxFuture)) {
457479
is Cancelled -> append("CANCELLED, cause=[${result.exception}]")
458-
else -> append("SUCCESS, result=[$result")
480+
else -> append("SUCCESS, result=[$result]")
459481
}
460482
} catch (e: CancellationException) {
461483
// `this` future was cancelled by `Future.cancel`. In this case there's no cause or message.
@@ -469,6 +491,7 @@ private class JobListenableFuture<T>(private val jobToCancel: Job): ListenableFu
469491
} else {
470492
append("PENDING, delegate=[$auxFuture]")
471493
}
494+
append(']')
472495
}
473496
}
474497

integration/kotlinx-coroutines-guava/test/ListenableFutureTest.kt

+44
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,50 @@ class ListenableFutureTest : TestBase() {
680680
finish(5)
681681
}
682682

683+
@Test
684+
fun testFutureCompletedExceptionally() = runTest {
685+
val testException = TestException()
686+
// NonCancellable to not propagate error to this scope.
687+
val future = future(context = NonCancellable) {
688+
throw testException
689+
}
690+
yield()
691+
assertTrue(future.isDone)
692+
assertFalse(future.isCancelled)
693+
val thrown = assertFailsWith<ExecutionException> { future.get() }
694+
assertEquals(testException, thrown.cause)
695+
}
696+
697+
@Test
698+
fun testAsListenableFutureCompletedExceptionally() = runTest {
699+
val testException = TestException()
700+
val deferred = CompletableDeferred<String>().apply {
701+
completeExceptionally(testException)
702+
}
703+
val asListenableFuture = deferred.asListenableFuture()
704+
assertTrue(asListenableFuture.isDone)
705+
assertFalse(asListenableFuture.isCancelled)
706+
val thrown = assertFailsWith<ExecutionException> { asListenableFuture.get() }
707+
assertEquals(testException, thrown.cause)
708+
}
709+
710+
@Test
711+
fun stressTestJobListenableFutureIsCancelledDoesNotThrow() = runTest {
712+
repeat(1000) {
713+
val deferred = CompletableDeferred<String>()
714+
val asListenableFuture = deferred.asListenableFuture()
715+
// We heed two threads to test a race condition.
716+
withContext(Dispatchers.Default) {
717+
val cancellationJob = launch {
718+
asListenableFuture.cancel(false)
719+
}
720+
while (!cancellationJob.isCompleted) {
721+
asListenableFuture.isCancelled // Shouldn't throw.
722+
}
723+
}
724+
}
725+
}
726+
683727
private inline fun <reified T: Throwable> ListenableFuture<*>.checkFutureException() {
684728
val e = assertFailsWith<ExecutionException> { get() }
685729
val cause = e.cause!!
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright 2016-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
3+
*/
4+
5+
package kotlinx.coroutines.guava
6+
7+
import kotlinx.coroutines.*
8+
import org.junit.Test
9+
import kotlin.test.*
10+
11+
class ListenableFutureToStringTest : TestBase() {
12+
@Test
13+
fun testSuccessfulFuture() = runTest {
14+
val deferred = CompletableDeferred("OK")
15+
val succeededFuture = deferred.asListenableFuture()
16+
val toString = succeededFuture.toString()
17+
assertTrue(message = "Unexpected format: $toString") {
18+
toString.matches(Regex("""kotlinx\.coroutines\.guava\.JobListenableFuture@[^\[]*\[status=SUCCESS, result=\[OK]]"""))
19+
}
20+
}
21+
22+
@Test
23+
fun testFailedFuture() = runTest {
24+
val exception = TestRuntimeException("test")
25+
val deferred = CompletableDeferred<String>().apply {
26+
completeExceptionally(exception)
27+
}
28+
val failedFuture = deferred.asListenableFuture()
29+
val toString = failedFuture.toString()
30+
assertTrue(message = "Unexpected format: $toString") {
31+
toString.matches(Regex("""kotlinx\.coroutines\.guava\.JobListenableFuture@[^\[]*\[status=FAILURE, cause=\[$exception]]"""))
32+
}
33+
}
34+
35+
@Test
36+
fun testPendingFuture() = runTest {
37+
val deferred = CompletableDeferred<String>()
38+
val pendingFuture = deferred.asListenableFuture()
39+
val toString = pendingFuture.toString()
40+
assertTrue(message = "Unexpected format: $toString") {
41+
toString.matches(Regex("""kotlinx\.coroutines\.guava\.JobListenableFuture@[^\[]*\[status=PENDING, delegate=\[.*]]"""))
42+
}
43+
}
44+
45+
@Test
46+
fun testCancelledCoroutineAsListenableFuture() = runTest {
47+
val exception = CancellationException("test")
48+
val deferred = CompletableDeferred<String>().apply {
49+
cancel(exception)
50+
}
51+
val cancelledFuture = deferred.asListenableFuture()
52+
val toString = cancelledFuture.toString()
53+
assertTrue(message = "Unexpected format: $toString") {
54+
toString.matches(Regex("""kotlinx\.coroutines\.guava\.JobListenableFuture@[^\[]*\[status=CANCELLED, cause=\[$exception]]"""))
55+
}
56+
}
57+
58+
@Test
59+
fun testCancelledFuture() = runTest {
60+
val deferred = CompletableDeferred<String>()
61+
val cancelledFuture = deferred.asListenableFuture().apply {
62+
cancel(false)
63+
}
64+
val toString = cancelledFuture.toString()
65+
assertTrue(message = "Unexpected format: $toString") {
66+
toString.matches(Regex("""kotlinx\.coroutines\.guava\.JobListenableFuture@[^\[]*\[status=CANCELLED]"""))
67+
}
68+
}
69+
}

0 commit comments

Comments
 (0)