diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginLifecycle.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginLifecycle.kt index f4ec3af0ef2..71b577699dc 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginLifecycle.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginLifecycle.kt @@ -8,6 +8,8 @@ package org.jetbrains.kotlin.gradle.plugin import org.gradle.api.Project import org.gradle.api.provider.Property import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.* +import org.jetbrains.kotlin.gradle.utils.CompletableFuture +import org.jetbrains.kotlin.gradle.utils.Future import org.jetbrains.kotlin.gradle.utils.failures import org.jetbrains.kotlin.gradle.utils.getOrPut import java.lang.ref.WeakReference @@ -114,6 +116,56 @@ internal val Project.kotlinPluginLifecycle: KotlinPluginLifecycle KotlinPluginLifecycleImpl(project) } +/** + * Future that will be completed once the project is considered 'Configured' + * ### Happy Path + * If the project configuration is successful (no exceptions thrown), then this Future will complete + * **after** [KotlinPluginLifecycle.Stage.ReadyForExecution] was fully executed. All coroutines within the regular lifecycle . + * In this case the value of this future will be [ProjectConfigurationResult.Success] + * + * ### Unhappy Path (Project configuration failed via exception) + * If the project configuration is unsuccessful (exception thrown) then this future will complete with + * [ProjectConfigurationResult.Failure], carrying the thrown exceptions. + * + * E.g. the following code: + * ```kotlin + * project.launchInStage(Stage.FinaliseCompilations) { + * throw Exception("My Error") + * } + * ``` + * + * will lead to: + * ```kotlin + * project.launch { + * val result = project.configured.await() + * val result as Failure + * val exception = result.failures.first() + * println(exception.message) // 'My Error' + * println(stage) // 'Stage.FinaliseCompilations' + * } + * ``` + * + * #### Failure case | Launching coroutines | Future.getOrThrow + * Even in case of failure it is still okay to further launch a new coroutine + * ```kotlin + * project.launch { + * val result = project.configured.await() as Failure + * val anotherJob = project.launch { ... } // <- executed right away + * val someFutureEvaluation = project.someFuture.getOrThrow() // <- will return value if all 'requirements' have been met. + * } + * ``` + * + * Note: [Future.getOrThrow] will throw if e.g. the lifecycle fails in a very early stage, but the Future requires + * some later data to be available. In this case, the Future still will only return 'sane' data. + */ +internal val Project.configured: Future + get() = configuredImpl + + +private val Project.configuredImpl: CompletableFuture + get() = extraProperties.getOrPut("org.jetbrains.kotlin.gradle.plugin.configured") { CompletableFuture() } + + /** * Will start the lifecycle, this shall be called before the [kotlinPluginLifecycle] is effectively used */ @@ -299,11 +351,16 @@ internal interface KotlinPluginLifecycle { ReadyForExecution; val previousOrFirst: Stage get() = previousOrNull ?: values.first() + val previousOrNull: Stage? get() = values.getOrNull(ordinal - 1) + val previousOrThrow: Stage get() = previousOrNull ?: throw IllegalArgumentException("'$this' does not have a next ${Stage::class.simpleName}") + val nextOrNull: Stage? get() = values.getOrNull(ordinal + 1) + val nextOrLast: Stage get() = nextOrNull ?: values.last() + val nextOrThrow: Stage get() = nextOrNull ?: throw IllegalArgumentException("'$this' does not have a next ${Stage::class.simpleName}") @@ -323,6 +380,11 @@ internal interface KotlinPluginLifecycle { } } + sealed class ProjectConfigurationResult { + object Success : ProjectConfigurationResult() + data class Failure(val failures: List) : ProjectConfigurationResult() + } + val project: Project val stage: Stage @@ -372,7 +434,7 @@ private class KotlinPluginLifecycleImpl(override val project: Project) : KotlinP private val loopRunning = AtomicBoolean(false) private val isStarted = AtomicBoolean(false) private val isFinishedSuccessfully = AtomicBoolean(false) - private val isFinishedWithExceptions = AtomicBoolean(false) + private val isFinishedWithFailures = AtomicBoolean(false) override var stage: Stage = Stage.values.first() private val properties = WeakHashMap, WeakReference>>() @@ -430,6 +492,7 @@ private class KotlinPluginLifecycleImpl(override val project: Project) : KotlinP try { val queue = enqueuedActions.getValue(stage) do { + project.state.rethrowFailure() val action = queue.removeFirstOrNull() action?.invoke(this) } while (action != null) @@ -441,17 +504,19 @@ private class KotlinPluginLifecycleImpl(override val project: Project) : KotlinP private fun finishWithFailures(failures: List) { assert(failures.isNotEmpty()) assert(isStarted.get()) - assert(!isFinishedWithExceptions.getAndSet(true)) + assert(!isFinishedWithFailures.getAndSet(true)) + project.configuredImpl.complete(ProjectConfigurationResult.Failure(failures)) } private fun finishSuccessfully() { assert(isStarted.get()) assert(!isFinishedSuccessfully.getAndSet(true)) + project.configuredImpl.complete(ProjectConfigurationResult.Success) } override fun enqueue(stage: Stage, action: KotlinPluginLifecycle.() -> Unit) { if (stage < this.stage) { - throw IllegalLifecycleException("Cannot enqueue Action for stage '${stage}' in current stage '${this.stage}'") + throw IllegalLifecycleException("Cannot enqueue Action for stage '$stage' in current stage '${this.stage}'") } /* @@ -460,12 +525,17 @@ private class KotlinPluginLifecycleImpl(override val project: Project) : KotlinP will be executed right away (no suspend necessary or wanted) */ if (isFinishedSuccessfully.get()) { - action() - return + return action() } - if (isFinishedWithExceptions.get()) { - throw IllegalLifecycleException("Cannot enqueue Action: Lifecycle already finished with Exceptions") + /* + Lifecycle finished, but some exceptions have been thrown. + In this case, an enqueue for future Stages is not allowed, since those will not be executed anymore. + Any enqueue in the current stage will be executed right away (no suspend necessary or wanted). + */ + if (isFinishedWithFailures.get()) { + return if (stage == this.stage) action() + else Unit } enqueuedActions.getValue(stage).addLast(action) diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginWrapper.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginWrapper.kt index 8f28f08b13a..694d872c336 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginWrapper.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginWrapper.kt @@ -256,6 +256,8 @@ abstract class KotlinBasePluginWrapper : DefaultKotlinBasePlugin() { ): Plugin private fun Project.scheduleDiagnosticChecksAndReporting() { + + launchInStage(KotlinPluginLifecycle.Stage.ReadyForExecution) { // Do not run checkers on projects which configuration finished with failure, // as the internal state can not be trusted at this point (e.g. not entire of the diff --git a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinPluginLifecycleTest.kt b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinPluginLifecycleTest.kt index 0d3b3aa2eb8..ab962b17571 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinPluginLifecycleTest.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinPluginLifecycleTest.kt @@ -3,21 +3,26 @@ * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. */ -@file:Suppress("FunctionName") +@file:Suppress("FunctionName", "ThrowableNotThrown") package org.jetbrains.kotlin.gradle.unitTests import org.gradle.api.ProjectConfigurationException import org.jetbrains.kotlin.gradle.plugin.* import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.IllegalLifecycleException +import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.ProjectConfigurationResult.Failure import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.Stage import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.Stage.* import org.jetbrains.kotlin.gradle.util.buildProjectWithMPP import org.jetbrains.kotlin.gradle.util.runLifecycleAwareTest +import org.jetbrains.kotlin.gradle.utils.future +import org.jetbrains.kotlin.tooling.core.withClosure import org.jetbrains.kotlin.tooling.core.withLinearClosure +import org.jetbrains.kotlin.utils.addToStdlib.cast import org.junit.Test import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference import kotlin.test.* class KotlinPluginLifecycleTest { @@ -186,6 +191,119 @@ class KotlinPluginLifecycleTest { assertTrue(executed.get()) } + @Test + fun `test - throwing an exception during buildscript evaluation - shall not execute afterEvaluate based stages`() { + val executed = AtomicReference() + lifecycle.enqueue(AfterEvaluateBuildscript) { + assertNull(executed.getAndSet(Throwable())) + } + + val thrownException = Exception() + + /* Provoking an exception during project.evaluate() */ + val exceptionWasProvoked = AtomicBoolean() + project.tasks.whenObjectAdded { + assertFalse(exceptionWasProvoked.getAndSet(true)) + throw thrownException + } + runCatching { project.evaluate() } + assertTrue(exceptionWasProvoked.get(), "Exception during '.evaluate()' was not provoked") + + /* Assert: The 'AfterEvaluate' based stage should not have been executed */ + executed.get()?.let { throwable -> + fail("AfterEvaluate based stage is not expected to be launched because of exception during .evaluate()", throwable) + } + + val exceptions = project.configured.getOrThrow().cast().failures.withClosure { listOfNotNull(it.cause) } + if (thrownException !in exceptions) fail("Expected 'thrownException' in lifecycle.finished") + } + + @Test + fun `test - throwing an exception during buildscript evaluation - will execute coroutine waiting for finished`() { + val thrownException = Exception() + + val executedAfterLifecycleFinished = AtomicBoolean(false) + project.launch { + val exceptions = project.configured.await().cast().failures.withClosure { listOfNotNull(it.cause) } + assertFalse(executedAfterLifecycleFinished.getAndSet(true)) + if (thrownException !in exceptions) fail("Expected 'thrownException' in lifecycle.finished") + } + + + /* Provoking an exception during project.evaluate() */ + val exceptionWasProvoked = AtomicBoolean() + project.tasks.whenObjectAdded { + assertFalse(exceptionWasProvoked.getAndSet(true)) + throw thrownException + } + runCatching { project.evaluate() } + assertTrue(exceptionWasProvoked.get(), "Exception during '.evaluate()' was not provoked") + assertTrue(executedAfterLifecycleFinished.get(), "Expected coroutine waiting for '.finished' to be executed") + } + + @Test + fun `test - throwing an exception in afterEvaluate based stage - allows getOrThrow on future`() { + val exception = IllegalStateException() + val future = project.future { AfterEvaluateBuildscript.await(); 42 } + val secondActionExecuted = AtomicBoolean(false) + + project.launchInStage(AfterEvaluateBuildscript) { + throw exception + } + + project.launch secondAction@{ + project.configured.await() + assertEquals(AfterEvaluateBuildscript, stage) + assertEquals(42, future.getOrThrow()) + assertEquals(420, project.future { AfterEvaluateBuildscript.await(); 420 }.getOrThrow()) + assertFalse(secondActionExecuted.getAndSet(true)) + } + + assertTrue(exception in assertFails { project.evaluate() }.withLinearClosure { it.cause }) + assertTrue(secondActionExecuted.get()) + } + + @Test + fun `test - throwing an exception in afterEvaluate based stage - will not execute coroutines in later stages`() { + val exception = IllegalStateException() + + val secondActionExecuted = AtomicBoolean(false) + val thirdActionExecuted = AtomicBoolean(false) + val fourthActionExecuted = AtomicBoolean(false) + + project.launchInStage(AfterEvaluateBuildscript) { + throw exception + } + + project.launchInStage(AfterEvaluateBuildscript.nextOrThrow) secondAction@{ + assertFalse(secondActionExecuted.getAndSet(true)) + } + + project.launch { + project.configured.await() + project.launchInStage(AfterEvaluateBuildscript.nextOrThrow) thirdAction@{ + assertFalse(thirdActionExecuted.getAndSet(true)) + } + } + + project.launch fourthAction@{ + project.configured.await() + AfterEvaluateBuildscript.nextOrThrow.await() + assertFalse(fourthActionExecuted.getAndSet(true)) + } + + val failure = assertFails { project.evaluate() } + + assertTrue( + exception in failure.withLinearClosure { it.cause }, + "Could not find 'exception' in failure cause\n${failure.stackTraceToString()}", + ) + + assertFalse(secondActionExecuted.get()) + assertFalse(thirdActionExecuted.get()) + assertFalse(fourthActionExecuted.get()) + } + @Test fun `test - stage property is correct`() { Stage.values().forEach { stage ->