From e09da60487e64ff86a6cef5d8f82cf8f984018d6 Mon Sep 17 00:00:00 2001 From: Roman Elizarov Date: Sun, 23 Sep 2018 19:16:04 +0300 Subject: [PATCH] Introduce SupervisorJob & supervisorScope This change also fixes propagation of cancellation for Job() constructor. When both Job() and SupervisorJob() are cancelled with exception (fail), they cancel their parent, too. So we have similar behavior between: * Job() and coroutineScope { ... } * SupervisorJob() and supervisorScope { ... } Fixes #576 --- .../kotlinx-coroutines-core.txt | 6 ++ .../src/CoroutineExceptionHandler.kt | 6 +- .../src/CoroutineScope.kt | 4 +- .../kotlinx-coroutines-core-common/src/Job.kt | 16 +++- .../src/JobSupport.kt | 7 +- .../src/Supervisor.kt | 67 ++++++++++++++++ .../test/JobTest.kt | 20 +++++ .../test/SupervisorTest.kt | 76 +++++++++++++++++++ .../exceptions/JobBasicCancellationTest.kt | 4 +- 9 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 common/kotlinx-coroutines-core-common/src/Supervisor.kt create mode 100644 common/kotlinx-coroutines-core-common/test/SupervisorTest.kt diff --git a/binary-compatibility-validator/reference-public-api/kotlinx-coroutines-core.txt b/binary-compatibility-validator/reference-public-api/kotlinx-coroutines-core.txt index b1c6765145..49e4acf0b6 100644 --- a/binary-compatibility-validator/reference-public-api/kotlinx-coroutines-core.txt +++ b/binary-compatibility-validator/reference-public-api/kotlinx-coroutines-core.txt @@ -511,6 +511,12 @@ public final class kotlinx/coroutines/experimental/ScheduledKt { public static synthetic fun withTimeoutOrNull$default (JLjava/util/concurrent/TimeUnit;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/experimental/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } +public final class kotlinx/coroutines/experimental/SupervisorKt { + public static final fun SupervisorJob (Lkotlinx/coroutines/experimental/Job;)Lkotlinx/coroutines/experimental/Job; + public static synthetic fun SupervisorJob$default (Lkotlinx/coroutines/experimental/Job;ILjava/lang/Object;)Lkotlinx/coroutines/experimental/Job; + public static final fun supervisorScope (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/experimental/Continuation;)Ljava/lang/Object; +} + public abstract interface class kotlinx/coroutines/experimental/ThreadContextElement : kotlin/coroutines/experimental/CoroutineContext$Element { public abstract fun restoreThreadContext (Lkotlin/coroutines/experimental/CoroutineContext;Ljava/lang/Object;)V public abstract fun updateThreadContext (Lkotlin/coroutines/experimental/CoroutineContext;)Ljava/lang/Object; diff --git a/common/kotlinx-coroutines-core-common/src/CoroutineExceptionHandler.kt b/common/kotlinx-coroutines-core-common/src/CoroutineExceptionHandler.kt index 55969ccb8e..31ba34b006 100644 --- a/common/kotlinx-coroutines-core-common/src/CoroutineExceptionHandler.kt +++ b/common/kotlinx-coroutines-core-common/src/CoroutineExceptionHandler.kt @@ -68,7 +68,11 @@ public inline fun CoroutineExceptionHandler(crossinline handler: (CoroutineConte } /** - * An optional element on the coroutine context to handle uncaught exceptions. + * An optional element in the coroutine context to handle uncaught exceptions. + * + * Normally, uncaught exceptions can only result from coroutines created using [launch][CoroutineScope.launch] builder. + * A coroutine that was created using [async][CoroutineScope.async] always catches all its exceptions and represents them + * in the resulting [Deferred] object. * * By default, when no handler is installed, uncaught exception are handled in the following way: * * If exception is [CancellationException] then it is ignored diff --git a/common/kotlinx-coroutines-core-common/src/CoroutineScope.kt b/common/kotlinx-coroutines-core-common/src/CoroutineScope.kt index 3935296a44..7a9010a7a4 100644 --- a/common/kotlinx-coroutines-core-common/src/CoroutineScope.kt +++ b/common/kotlinx-coroutines-core-common/src/CoroutineScope.kt @@ -146,7 +146,9 @@ object GlobalScope : CoroutineScope { * The provided scope inherits its [coroutineContext][CoroutineScope.coroutineContext] from the outer scope, but overrides * context's [Job]. * - * This methods returns as soon as given block and all launched from within the scope children coroutines are completed. + * This function is designed for a _parallel decomposition_ of work. When any child coroutine in this scope fails, + * this scope fails and all the rest of the children are cancelled (for a different behavior see [supervisorScope]). + * This function returns as soon as given block and all its children coroutines are completed. * Example of the scope usages looks like this: * * ``` diff --git a/common/kotlinx-coroutines-core-common/src/Job.kt b/common/kotlinx-coroutines-core-common/src/Job.kt index 36a9c8a518..c2f083a6e1 100644 --- a/common/kotlinx-coroutines-core-common/src/Job.kt +++ b/common/kotlinx-coroutines-core-common/src/Job.kt @@ -23,7 +23,9 @@ import kotlin.coroutines.experimental.* * can [cancel] its own children (including all their children recursively) without cancelling itself. * * The most basic instances of [Job] are created with [launch][CoroutineScope.launch] coroutine builder or with a - * `Job()` factory function. + * `Job()` factory function. By default, a failure of a any of the job's children leads to an immediately failure + * of its parent and cancellation of the rest of its children. This behavior can be customized using [SupervisorJob]. + * * Conceptually, an execution of the job does not produce a result value. Jobs are launched solely for their * side-effects. See [Deferred] interface for a job that produces a result. * @@ -375,8 +377,16 @@ public interface Job : CoroutineContext.Element { } /** - * Creates a new job object in an _active_ state. - * It is optionally a child of a [parent] job. + * Creates a new job object in an active state. + * A failure of any child of this job immediately causes this job to fail, too, and cancels the rest of its children. + * + * To handle children failure independently of each other use [SupervisorJob]. + * + * If [parent] job is specified, then this job becomes a child job of its parent and + * is cancelled when its parent fails or is cancelled. All this job's children are cancelled in this case, too. + * The invocation of [cancel][Job.cancel] with exception (other than [CancellationException]) on this job also cancels parent. + * + * @param parent an optional parent job. */ @Suppress("FunctionName") public fun Job(parent: Job? = null): Job = JobImpl(parent) diff --git a/common/kotlinx-coroutines-core-common/src/JobSupport.kt b/common/kotlinx-coroutines-core-common/src/JobSupport.kt index 63bc6bb4fb..ff666f94c7 100644 --- a/common/kotlinx-coroutines-core-common/src/JobSupport.kt +++ b/common/kotlinx-coroutines-core-common/src/JobSupport.kt @@ -607,13 +607,13 @@ internal open class JobSupport constructor(active: Boolean) : Job, ChildJob, Sel public override fun cancel(cause: Throwable?): Boolean = cancelImpl(cause) && handlesException - // parent is cancelling child + // Parent is cancelling child public final override fun parentCancelled(parentJob: Job) { cancelImpl(parentJob) } - // child was cancelled with cause - internal fun childCancelled(cause: Throwable): Boolean = + // Child was cancelled with cause + public open fun childCancelled(cause: Throwable): Boolean = cancelImpl(cause) && handlesException // cause is Throwable or Job when cancelChild was invoked @@ -1171,6 +1171,7 @@ private class Empty(override val isActive: Boolean) : Incomplete { internal class JobImpl(parent: Job? = null) : JobSupport(true) { init { initParentJobInternal(parent) } + override val cancelsParent: Boolean get() = true override val onCancelComplete get() = true override val handlesException: Boolean get() = false } diff --git a/common/kotlinx-coroutines-core-common/src/Supervisor.kt b/common/kotlinx-coroutines-core-common/src/Supervisor.kt new file mode 100644 index 0000000000..2e4fe89d3f --- /dev/null +++ b/common/kotlinx-coroutines-core-common/src/Supervisor.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +package kotlinx.coroutines.experimental + +import kotlin.coroutines.experimental.* + +/** + * Creates a new _supervisor_ job object in an active state. + * Children of a supervisor job can fail independently of each other. + * + * A failure or cancellation of a child does not cause the supervisor job to fail and does not affect its other children, + * so a supervisor can implement a custom policy for handling failures of its children: + * + * * A failure of a child job that was created using [launch][CoroutineScope.launch] can be handled via [CoroutineExceptionHandler] in the context. + * * A failure of a child job that was created using [async][CoroutineScope.async] can be handled via [Deferred.await] on the resulting deferred value. + * + * If [parent] job is specified, then this supervisor job becomes a child job of its parent and is cancelled when its + * parent fails or is cancelled. All this supervisor's children are cancelled in this case, too. The invocation of + * of [cancel][Job.cancel] with exception (other than [CancellationException]) on this supervisor job also cancels parent. + * + * @param parent an optional parent job. + */ +@Suppress("FunctionName") +public fun SupervisorJob(parent: Job? = null) : Job = SupervisorJobImpl(parent) + +/** + * Creates new [CoroutineScope] with [SupervisorJob] and calls the specified suspend block with this scope. + * The provided scope inherits its [coroutineContext][CoroutineScope.coroutineContext] from the outer scope, but overrides + * context's [Job] with [SupervisorJob]. + * + * A failure of a child does not cause this scope to fail and does not affect its other children, + * so a custom policy for handling failures of its children can be implemented. See [SupervisorJob] for details. + */ +public suspend fun supervisorScope(block: suspend CoroutineScope.() -> R): R { + // todo: optimize implementation to a single allocated object + // todo: fix copy-and-paste with coroutineScope + val owner = SupervisorCoroutine(coroutineContext) + owner.start(CoroutineStart.UNDISPATCHED, owner, block) + owner.join() + if (owner.isCancelled) { + throw owner.getCancellationException().let { it.cause ?: it } + } + val state = owner.state + if (state is CompletedExceptionally) { + throw state.cause + } + @Suppress("UNCHECKED_CAST") + return state as R + +} + +private class SupervisorJobImpl(parent: Job?) : JobSupport(true) { + init { initParentJobInternal(parent) } + override val cancelsParent: Boolean get() = true + override val onCancelComplete get() = true + override val handlesException: Boolean get() = false + override fun childCancelled(cause: Throwable): Boolean = false +} + +private class SupervisorCoroutine( + parentContext: CoroutineContext +) : AbstractCoroutine(parentContext, true) { + override val cancelsParent: Boolean get() = true + override fun childCancelled(cause: Throwable): Boolean = false +} diff --git a/common/kotlinx-coroutines-core-common/test/JobTest.kt b/common/kotlinx-coroutines-core-common/test/JobTest.kt index 1379861beb..9e02419811 100644 --- a/common/kotlinx-coroutines-core-common/test/JobTest.kt +++ b/common/kotlinx-coroutines-core-common/test/JobTest.kt @@ -188,4 +188,24 @@ class JobTest : TestBase() { deferred.join() finish(3) } + + @Test + fun testJobWithParentCancelNormally() { + val parent = Job() + val job = Job(parent) + job.cancel() + assertTrue(job.isCancelled) + assertFalse(parent.isCancelled) + } + + @Test + fun testJobWithParentCancelException() { + val parent = Job() + val job = Job(parent) + job.cancel(TestException()) + assertTrue(job.isCancelled) + assertTrue(parent.isCancelled) + } + + private class TestException : Exception() } diff --git a/common/kotlinx-coroutines-core-common/test/SupervisorTest.kt b/common/kotlinx-coroutines-core-common/test/SupervisorTest.kt new file mode 100644 index 0000000000..11b939d3a4 --- /dev/null +++ b/common/kotlinx-coroutines-core-common/test/SupervisorTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2016-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +@file:Suppress("NAMED_ARGUMENTS_NOT_ALLOWED") // KT-21913 + +package kotlinx.coroutines.experimental + +import kotlin.test.* + +class SupervisorTest : TestBase() { + @Test + fun testSupervisorJob() = runTest( + unhandled = listOf( + { it -> it is TestException2 }, + { it -> it is TestException1 } + ) + ) { + expect(1) + val supervisor = SupervisorJob() + val job1 = launch(supervisor + CoroutineName("job1")) { + expect(2) + yield() // to second child + expect(4) + throw TestException1() + } + val job2 = launch(supervisor + CoroutineName("job2")) { + expect(3) + throw TestException2() + } + joinAll(job1, job2) + finish(5) + assertTrue(job1.isCancelled) + assertTrue(job2.isCancelled) + } + + @Test + fun testSupervisorScope() = runTest( + unhandled = listOf( + { it -> it is TestException1 }, + { it -> it is TestException2 } + ) + ) { + val result = supervisorScope { + launch { + throw TestException1() + } + launch { + throw TestException2() + } + "OK" + } + assertEquals("OK", result) + } + + @Test + fun testSupervisorWithParentCancelNormally() { + val parent = Job() + val supervisor = SupervisorJob(parent) + supervisor.cancel() + assertTrue(supervisor.isCancelled) + assertFalse(parent.isCancelled) + } + + @Test + fun testSupervisorWithParentCancelException() { + val parent = Job() + val supervisor = SupervisorJob(parent) + supervisor.cancel(TestException1()) + assertTrue(supervisor.isCancelled) + assertTrue(parent.isCancelled) + } + + private class TestException1 : Exception() + private class TestException2 : Exception() +} \ No newline at end of file diff --git a/core/kotlinx-coroutines-core/test/exceptions/JobBasicCancellationTest.kt b/core/kotlinx-coroutines-core/test/exceptions/JobBasicCancellationTest.kt index 8421d5e83b..50592c537a 100644 --- a/core/kotlinx-coroutines-core/test/exceptions/JobBasicCancellationTest.kt +++ b/core/kotlinx-coroutines-core/test/exceptions/JobBasicCancellationTest.kt @@ -121,12 +121,10 @@ class JobBasicCancellationTest : TestBase() { expect(1) val child = Job(coroutineContext[Job]) expect(2) - assertFalse(child.cancel(IOException())) + assertFalse(child.cancel()) child.join() - assertTrue(child.getCancellationException().cause is IOException) expect(3) } - parent.join() finish(4) }