Skip to content

Commit 60eefec

Browse files
authored
Breaking change: Guava future coroutine builder shouldn't report to CoroutineExceptionHandler (Kotlin#2840)
This change makes `future` coroutine builder consistent with `java.util.concurrent.FutureTask` which also drops exceptions that happen after successful cancellation. Fixes Kotlin#2774 Fixes Kotlin#2791
1 parent e4bee74 commit 60eefec

File tree

2 files changed

+80
-44
lines changed

2 files changed

+80
-44
lines changed

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

+24-21
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import kotlin.coroutines.*
1414
/**
1515
* Starts [block] in a new coroutine and returns a [ListenableFuture] pointing to its result.
1616
*
17-
* The coroutine is immediately started. Passing [CoroutineStart.LAZY] to [start] throws
17+
* The coroutine is started immediately. Passing [CoroutineStart.LAZY] to [start] throws
1818
* [IllegalArgumentException], because Futures don't have a way to start lazily.
1919
*
2020
* When the created coroutine [isCompleted][Job.isCompleted], it will try to
@@ -35,10 +35,12 @@ import kotlin.coroutines.*
3535
* See [newCoroutineContext][CoroutineScope.newCoroutineContext] for a description of debugging
3636
* facilities.
3737
*
38-
* Note that the error and cancellation semantics of [future] are _subtly different_ than [asListenableFuture]'s.
39-
* In particular, any exception that happens in the coroutine after returned future is
40-
* successfully cancelled will be passed to the [CoroutineExceptionHandler] from the [context].
41-
* See [ListenableFutureCoroutine] for details.
38+
* Note that the error and cancellation semantics of [future] are _different_ than [async]'s.
39+
* In contrast to [Deferred], [Future] doesn't have an intermediate `Cancelling` state. If
40+
* the returned `Future` is successfully cancelled, and `block` throws afterward, the thrown
41+
* error is dropped, and getting the `Future`'s value will throw a `CancellationException` with
42+
* no cause. This is to match the specification and behavior of
43+
* `java.util.concurrent.FutureTask`.
4244
*
4345
* @param context added overlaying [CoroutineScope.coroutineContext] to form the new context.
4446
* @param start coroutine start option. The default value is [CoroutineStart.DEFAULT].
@@ -241,8 +243,8 @@ public suspend fun <T> ListenableFuture<T>.await(): T {
241243

242244
return suspendCancellableCoroutine { cont: CancellableContinuation<T> ->
243245
addListener(
244-
ToContinuation(this, cont),
245-
MoreExecutors.directExecutor())
246+
ToContinuation(this, cont),
247+
MoreExecutors.directExecutor())
246248
cont.invokeOnCancellation {
247249
cancel(false)
248250
}
@@ -284,16 +286,13 @@ private class ToContinuation<T>(
284286
* By documented contract, a [Future] has been cancelled if
285287
* and only if its `isCancelled()` method returns true.
286288
*
287-
* Any error that occurs after successfully cancelling a [ListenableFuture] will be passed
288-
* to the [CoroutineExceptionHandler] from the context. The contract of [Future] does not permit
289-
* it to return an error after it is successfully cancelled.
290-
*
291-
* By calling [asListenableFuture] on a [Deferred], any error that occurs after successfully
292-
* cancelling the [ListenableFuture] representation of the [Deferred] will _not_ be passed to
293-
* the [CoroutineExceptionHandler]. Cancelling a [Deferred] places that [Deferred] in the
294-
* cancelling/cancelled states defined by [Job], which _can_ show the error. It's assumed that
295-
* the [Deferred] pointing to the task will be used to observe any error outcome occurring after
296-
* cancellation.
289+
* Any error that occurs after successfully cancelling a [ListenableFuture] is lost.
290+
* The contract of [Future] does not permit it to return an error after it is successfully cancelled.
291+
* On the other hand, we can't report an unhandled exception to [CoroutineExceptionHandler],
292+
* otherwise [Future.cancel] can lead to an app crash which arguably is a contract violation.
293+
* In contrast to [Future] which can't change its outcome after a successful cancellation,
294+
* cancelling a [Deferred] places that [Deferred] in the cancelling/cancelled states defined by [Job],
295+
* which _can_ show the error.
297296
*
298297
* This may be counterintuitive, but it maintains the error and cancellation contracts of both
299298
* the [Deferred] and [ListenableFuture] types, while permitting both kinds of promise to point
@@ -312,10 +311,14 @@ private class ListenableFutureCoroutine<T>(
312311
}
313312

314313
override fun onCancelled(cause: Throwable, handled: Boolean) {
315-
if (!future.completeExceptionallyOrCancel(cause) && !handled) {
316-
// prevents loss of exception that was not handled by parent & could not be set to JobListenableFuture
317-
handleCoroutineException(context, cause)
318-
}
314+
// Note: if future was cancelled in a race with a cancellation of this
315+
// coroutine, and the future was successfully cancelled first, the cause of coroutine
316+
// cancellation is dropped in this promise. A Future can only be completed once.
317+
//
318+
// This is consistent with FutureTask behaviour. A race between a Future.cancel() and
319+
// a FutureTask.setException() for the same Future will similarly drop the
320+
// cause of a failure-after-cancellation.
321+
future.completeExceptionallyOrCancel(cause)
319322
}
320323
}
321324

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

+56-23
Original file line numberDiff line numberDiff line change
@@ -555,19 +555,15 @@ class ListenableFutureTest : TestBase() {
555555
}
556556

557557
@Test
558-
fun testUnhandledExceptionOnExternalCancellation() = runTest(
559-
unhandled = listOf(
560-
{ it -> it is TestException } // exception is unhandled because there is no parent
561-
)
562-
) {
558+
fun testUnhandledExceptionOnExternalCancellation() = runTest {
563559
expect(1)
564560
// No parent here (NonCancellable), so nowhere to propagate exception
565561
val result = future(NonCancellable + Dispatchers.Unconfined) {
566562
try {
567563
delay(Long.MAX_VALUE)
568564
} finally {
569565
expect(2)
570-
throw TestException() // this exception cannot be handled
566+
throw TestException() // this exception cannot be handled and is set to be lost.
571567
}
572568
}
573569
result.cancel(true)
@@ -708,23 +704,6 @@ class ListenableFutureTest : TestBase() {
708704
assertEquals(testException, thrown.cause)
709705
}
710706

711-
@Test
712-
fun stressTestJobListenableFutureIsCancelledDoesNotThrow() = runTest {
713-
repeat(1000) {
714-
val deferred = CompletableDeferred<String>()
715-
val asListenableFuture = deferred.asListenableFuture()
716-
// We heed two threads to test a race condition.
717-
withContext(Dispatchers.Default) {
718-
val cancellationJob = launch {
719-
asListenableFuture.cancel(false)
720-
}
721-
while (!cancellationJob.isCompleted) {
722-
asListenableFuture.isCancelled // Shouldn't throw.
723-
}
724-
}
725-
}
726-
}
727-
728707
private inline fun <reified T: Throwable> ListenableFuture<*>.checkFutureException() {
729708
val e = assertFailsWith<ExecutionException> { get() }
730709
val cause = e.cause!!
@@ -775,4 +754,58 @@ class ListenableFutureTest : TestBase() {
775754
assertEquals(count, completed.get())
776755
}
777756
}
757+
758+
@Test
759+
fun futurePropagatesExceptionToParentAfterCancellation() = runTest {
760+
val latch = CompletableDeferred<Boolean>()
761+
val parent = Job()
762+
val scope = CoroutineScope(parent)
763+
val exception = TestException("propagated to parent")
764+
val future = scope.future {
765+
withContext(NonCancellable) {
766+
latch.await()
767+
throw exception
768+
}
769+
}
770+
future.cancel(true)
771+
latch.complete(true)
772+
parent.join()
773+
assertTrue(parent.isCancelled)
774+
assertEquals(exception, parent.getCancellationException().cause)
775+
}
776+
777+
// Stress tests.
778+
779+
@Test
780+
fun testFutureDoesNotReportToCoroutineExceptionHandler() = runTest {
781+
repeat(1000) {
782+
supervisorScope { // Don't propagate failures in children to parent and other children.
783+
val innerFuture = SettableFuture.create<Unit>()
784+
val outerFuture = async { innerFuture.await() }
785+
786+
withContext(Dispatchers.Default) {
787+
launch { innerFuture.setException(TestException("can be lost")) }
788+
launch { outerFuture.cancel() }
789+
// nothing should be reported to CoroutineExceptionHandler, otherwise `Future.cancel` contract violation.
790+
}
791+
}
792+
}
793+
}
794+
795+
@Test
796+
fun testJobListenableFutureIsCancelledDoesNotThrow() = runTest {
797+
repeat(1000) {
798+
val deferred = CompletableDeferred<String>()
799+
val asListenableFuture = deferred.asListenableFuture()
800+
// We heed two threads to test a race condition.
801+
withContext(Dispatchers.Default) {
802+
val cancellationJob = launch {
803+
asListenableFuture.cancel(false)
804+
}
805+
while (!cancellationJob.isCompleted) {
806+
asListenableFuture.isCancelled // Shouldn't throw.
807+
}
808+
}
809+
}
810+
}
778811
}

0 commit comments

Comments
 (0)