[Gradle] Introduce Project.configured: Future<ProjectConfigurationResult> API

KT-58275
This commit is contained in:
Sebastian Sellmair
2023-05-03 18:13:53 +02:00
committed by Space Team
parent f616fffeb0
commit d170897dbf
3 changed files with 198 additions and 8 deletions
@@ -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<ProjectConfigurationResult>
get() = configuredImpl
private val Project.configuredImpl: CompletableFuture<ProjectConfigurationResult>
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<Throwable>) : 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<Property<*>, WeakReference<LifecycleAwareProperty<*>>>()
@@ -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<Throwable>) {
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)
@@ -256,6 +256,8 @@ abstract class KotlinBasePluginWrapper : DefaultKotlinBasePlugin() {
): Plugin<Project>
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
@@ -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<Throwable>()
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<Failure>().failures.withClosure<Throwable> { 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<Failure>().failures.withClosure<Throwable> { 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 ->