From cc9bab41056be329a2e8251816c2a268c0896ed0 Mon Sep 17 00:00:00 2001 From: Sebastian Sellmair Date: Tue, 7 Mar 2023 17:13:57 +0100 Subject: [PATCH] [Gradle] Implement initial 'KotlinMultiplatformPluginLifecycle' KT-34662 --- .../KotlinMultiplatformPluginLifecycle.kt | 103 +++++++++ .../gradle/plugin/KotlinPluginWrapper.kt | 1 + .../plugin/sources/AbstractKotlinSourceSet.kt | 7 + .../KotlinMetadataTargetConfigurator.kt | 2 +- .../KotlinMultiplatformPluginLifecycleTest.kt | 213 ++++++++++++++++++ 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinMultiplatformPluginLifecycle.kt create mode 100644 libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinMultiplatformPluginLifecycleTest.kt diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinMultiplatformPluginLifecycle.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinMultiplatformPluginLifecycle.kt new file mode 100644 index 00000000000..7c081a460ce --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinMultiplatformPluginLifecycle.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +package org.jetbrains.kotlin.gradle.plugin + +import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginLifecycle.IllegalLifecycleException +import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginLifecycle.Stage +import org.jetbrains.kotlin.gradle.utils.getOrPut +import java.util.concurrent.atomic.AtomicBoolean + +internal val Project.kotlinMultiplatformPluginLifecycle: KotlinMultiplatformPluginLifecycle + get() = extraProperties.getOrPut(KotlinMultiplatformPluginLifecycle::class.java.name) { KotlinMultiplatformPluginLifecycleImpl() } + +internal fun Project.startKotlinMultiplatformPluginLifecycle() { + (kotlinMultiplatformPluginLifecycle as KotlinMultiplatformPluginLifecycleImpl).start(this) +} + +internal inline fun Project.enqueue(stage: Stage, crossinline action: Project.() -> Unit) { + kotlinMultiplatformPluginLifecycle.enqueue(stage) { action() } +} + +internal interface KotlinMultiplatformPluginLifecycle { + enum class Stage { + Configure, + AfterEvaluate, + FinaliseRefinesEdges, + FinaliseCompilations, + Finalised + } + + val stage: Stage + + fun enqueue(stage: Stage, action: () -> Unit) + + class IllegalLifecycleException(message: String) : IllegalStateException(message) +} + +private class KotlinMultiplatformPluginLifecycleImpl : KotlinMultiplatformPluginLifecycle { + private val enqueuedStages = ArrayDeque(Stage.values().toList()) + private val enqueuedActions = Stage.values().associateWith { ArrayDeque<() -> Unit>() } + private var configureLoopRunning = false + private var isStarted = AtomicBoolean(false) + + fun start(project: Project) { + check(!isStarted.getAndSet(true)) { + "${KotlinMultiplatformPluginLifecycle::class.java.name} already started" + } + + check(!project.state.executed) { + "${KotlinMultiplatformPluginLifecycle::class.java.name} cannot be started in ProjectState '${project.state}'" + } + + project.whenEvaluated { + executeStage(project, enqueuedStages.removeFirst()) + } + } + + private fun executeStage(project: Project, stage: Stage) { + this.stage = stage + val queue = enqueuedActions.getValue(stage) + do { + val action = queue.removeFirstOrNull() + action?.invoke() + } while (action != null) + val nextStage = enqueuedStages.removeFirstOrNull() ?: return + + project.afterEvaluate { + executeStage(project, nextStage) + } + } + + private fun startConfigureLoopIfNecessary() { + check(stage == Stage.Configure) { "Cannot start 'configure loop' on stage '$stage'" } + if (configureLoopRunning) return + configureLoopRunning = true + try { + val queue = enqueuedActions.getValue(Stage.Configure) + do { + val action = queue.removeFirstOrNull() + action?.invoke() + } while (action != null) + } finally { + configureLoopRunning = false + } + } + + override var stage: Stage = enqueuedStages.removeFirst() + + override fun enqueue(stage: Stage, action: () -> Unit) { + if (stage < this.stage) { + throw IllegalLifecycleException("Cannot enqueue Action for stage '${this.stage}' in current stage '${this.stage}'") + } + + enqueuedActions.getValue(stage).addLast(action) + + if (stage == Stage.Configure) { + startConfigureLoopIfNecessary() + } + } +} 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 530a990878f..bdeac18371f 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 @@ -323,6 +323,7 @@ abstract class AbstractKotlinMultiplatformPluginWrapper : KotlinBasePluginWrappe override fun apply(project: Project) { super.apply(project) + project.startKotlinMultiplatformPluginLifecycle() project.runMultiplatformAndroidGradlePluginCompatibilityHealthCheckWhenAndroidIsApplied() } diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/sources/AbstractKotlinSourceSet.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/sources/AbstractKotlinSourceSet.kt index 7d10c0c8c62..d0085ec8157 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/sources/AbstractKotlinSourceSet.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/sources/AbstractKotlinSourceSet.kt @@ -8,7 +8,9 @@ package org.jetbrains.kotlin.gradle.plugin.sources import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation +import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginLifecycle import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet +import org.jetbrains.kotlin.gradle.plugin.kotlinMultiplatformPluginLifecycle import org.jetbrains.kotlin.gradle.utils.MutableObservableSet import org.jetbrains.kotlin.gradle.utils.MutableObservableSetImpl import org.jetbrains.kotlin.gradle.utils.ObservableSet @@ -31,6 +33,11 @@ abstract class AbstractKotlinSourceSet : InternalKotlinSourceSet { final override fun dependsOn(other: KotlinSourceSet) { if (other == this) return + + assert(project.kotlinMultiplatformPluginLifecycle.stage <= KotlinMultiplatformPluginLifecycle.Stage.FinaliseRefinesEdges) { + "Illegal 'dependsOn' call in stage '${project.kotlinMultiplatformPluginLifecycle.stage}'" + } + /* Circular dependsOn hierarchies are not allowed: Throw if this SourceSet is already present in the dependsOnClosure of 'other' diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/metadata/KotlinMetadataTargetConfigurator.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/metadata/KotlinMetadataTargetConfigurator.kt index 8150db67378..96fb7dac743 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/metadata/KotlinMetadataTargetConfigurator.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/metadata/KotlinMetadataTargetConfigurator.kt @@ -175,7 +175,7 @@ class KotlinMetadataTargetConfigurator : private fun createMetadataCompilationsForCommonSourceSets( target: KotlinMetadataTarget, allMetadataJar: TaskProvider - ) = target.project.whenEvaluated { + ) = target.project.enqueue(KotlinMultiplatformPluginLifecycle.Stage.FinaliseCompilations) { // Do this after all targets are configured by the user build script val publishedCommonSourceSets: Set = getCommonSourceSetsForMetadataCompilation(project) diff --git a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinMultiplatformPluginLifecycleTest.kt b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinMultiplatformPluginLifecycleTest.kt new file mode 100644 index 00000000000..e05dfe3f235 --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinMultiplatformPluginLifecycleTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors. + * 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") + +package org.jetbrains.kotlin.gradle.unitTests + +import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginLifecycle +import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginLifecycle.Stage.* +import org.jetbrains.kotlin.gradle.plugin.kotlinMultiplatformPluginLifecycle +import org.jetbrains.kotlin.gradle.util.buildProjectWithMPP +import org.junit.Test +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.* + +class KotlinMultiplatformPluginLifecycleTest { + + private val project = buildProjectWithMPP() + private val lifecycle = project.kotlinMultiplatformPluginLifecycle + + @Test + fun `test - configure phase is executed right away`() { + val invocations = AtomicInteger(0) + lifecycle.enqueue(Configure) { + invocations.incrementAndGet() + } + assertEquals(1, invocations.get(), "Expected one invocation") + } + + @Test + fun `test - configure phase - nested enqueue - is executed as queue`() { + val outerInvocations = AtomicInteger(0) + val nestedAInvocations = AtomicInteger(0) + val nestedBInvocations = AtomicInteger(0) + val nestedCInvocations = AtomicInteger(0) + lifecycle.enqueue(Configure) { + assertEquals(1, outerInvocations.incrementAndGet()) + + lifecycle.enqueue(Configure) nestedA@{ + assertEquals(0, nestedBInvocations.get(), "Expected nestedA to be executed before nestedB") + assertEquals(1, nestedAInvocations.incrementAndGet()) + + lifecycle.enqueue(Configure) nestedC@{ + assertEquals(1, nestedBInvocations.get(), "Expected nestedB to be executed before nestedC") + assertEquals(1, nestedCInvocations.incrementAndGet()) + } + } + + lifecycle.enqueue(Configure) nestedB@{ + assertEquals(1, nestedAInvocations.get(), "Expected nestedA to be executed before nestedB") + assertEquals(0, nestedCInvocations.get(), "Expected nestedB to be executed before nestedC") + assertEquals(1, nestedBInvocations.incrementAndGet()) + } + } + + assertEquals(1, outerInvocations.get()) + assertEquals(1, nestedAInvocations.get()) + assertEquals(1, nestedBInvocations.get()) + assertEquals(1, nestedCInvocations.get()) + } + + @Test + fun `test - all stages are executed in order`() { + val invocations = KotlinMultiplatformPluginLifecycle.Stage.values().associateWith { AtomicInteger(0) } + KotlinMultiplatformPluginLifecycle.Stage.values().toList().forEach { stage -> + lifecycle.enqueue(stage) { + KotlinMultiplatformPluginLifecycle.Stage.values().forEach { otherStage -> + when { + otherStage.ordinal < stage.ordinal -> assertEquals(1, invocations.getValue(otherStage).get()) + otherStage.ordinal == stage.ordinal -> assertEquals(1, invocations.getValue(stage).incrementAndGet()) + else -> assertEquals(0, invocations.getValue(otherStage).get()) + } + } + } + } + + project.evaluate() + + invocations.forEach { (stage, invocations) -> + assertEquals(1, invocations.get(), "Expected stage '$stage' to be executed") + } + } + + @Test + fun `test - afterEvaluate based stage executes queue in order`() { + val action1Invocations = AtomicInteger(0) + val action2Invocations = AtomicInteger(0) + val action3Invocations = AtomicInteger(0) + + lifecycle.enqueue(Finalised) action3@{ + assertEquals(1, action1Invocations.get(), "Expected action1 to be executed before action3") + assertEquals(1, action2Invocations.get(), "Expected action2 to be executed before action3") + assertEquals(1, action3Invocations.incrementAndGet()) + } + + lifecycle.enqueue(AfterEvaluate) action1@{ + assertEquals(0, action2Invocations.get(), "Expected action1 to be executed before action2") + assertEquals(0, action3Invocations.get(), "Expected action1 to be executed before action3") + assertEquals(1, action1Invocations.incrementAndGet()) + } + + lifecycle.enqueue(AfterEvaluate) action2@{ + assertEquals(1, action1Invocations.get(), "Expected action1 to be executed before action2") + assertEquals(0, action3Invocations.get(), "Expected action2 to be executed before action3") + assertEquals(1, action2Invocations.incrementAndGet()) + } + + assertEquals(0, action1Invocations.get()) + assertEquals(0, action2Invocations.get()) + assertEquals(0, action3Invocations.get()) + + project.evaluate() + + assertEquals(1, action1Invocations.get()) + assertEquals(1, action2Invocations.get()) + assertEquals(1, action3Invocations.get()) + } + + @Test + fun `test - afterEvaluate based stage - allows enqueue in current stage`() { + val outerInvocations = AtomicInteger(0) + val nestedAInvocations = AtomicInteger(0) + val nestedBInvocations = AtomicInteger(0) + val nestedCInvocations = AtomicInteger(0) + lifecycle.enqueue(AfterEvaluate) { + assertEquals(1, outerInvocations.incrementAndGet()) + + lifecycle.enqueue(AfterEvaluate) nestedA@{ + assertEquals(0, nestedBInvocations.get(), "Expected nestedA to be executed before nestedB") + assertEquals(1, nestedAInvocations.incrementAndGet()) + + lifecycle.enqueue(AfterEvaluate) nestedC@{ + assertEquals(1, nestedBInvocations.get(), "Expected nestedB to be executed before nestedC") + assertEquals(1, nestedCInvocations.incrementAndGet()) + } + } + + lifecycle.enqueue(AfterEvaluate) nestedB@{ + assertEquals(1, nestedAInvocations.get(), "Expected nestedA to be executed before nestedB") + assertEquals(0, nestedCInvocations.get(), "Expected nestedB to be executed before nestedC") + assertEquals(1, nestedBInvocations.incrementAndGet()) + } + } + + assertEquals(0, outerInvocations.get()) + assertEquals(0, nestedAInvocations.get()) + assertEquals(0, nestedBInvocations.get()) + assertEquals(0, nestedCInvocations.get()) + + project.evaluate() + + assertEquals(1, outerInvocations.get()) + assertEquals(1, nestedAInvocations.get()) + assertEquals(1, nestedBInvocations.get()) + assertEquals(1, nestedCInvocations.get()) + } + + @Test + fun `test - enqueue of already executed stage - throws exception`() { + val executed = AtomicBoolean(false) + lifecycle.enqueue(Finalised) { + assertFailsWith { + lifecycle.enqueue(AfterEvaluate) { fail("This code shall not be executed!") } + } + assertFalse(executed.getAndSet(true)) + } + + project.evaluate() + assertTrue(executed.get()) + } + + @Test + fun `test - stage property is correct`() { + KotlinMultiplatformPluginLifecycle.Stage.values().forEach { stage -> + lifecycle.enqueue(stage) { + assertEquals(lifecycle.stage, stage) + } + } + project.evaluate() + } + + @Test + fun `test - invoke configure twice`() { + val action1Invocations = AtomicInteger(0) + val action2Invocations = AtomicInteger(0) + val action3Invocations = AtomicInteger(0) + + lifecycle.enqueue(Configure) action1@{ + assertEquals(0, action2Invocations.get()) + assertEquals(0, action3Invocations.get()) + assertEquals(1, action1Invocations.incrementAndGet()) + } + + lifecycle.enqueue(Configure) action2@{ + lifecycle.enqueue(Configure) action3@{ + assertEquals(1, action1Invocations.get()) + assertEquals(1, action2Invocations.get()) + assertEquals(1, action3Invocations.incrementAndGet()) + } + + assertEquals(1, action1Invocations.get()) + assertEquals(0, action3Invocations.get()) + assertEquals(1, action2Invocations.incrementAndGet()) + } + + assertEquals(1, action1Invocations.get()) + assertEquals(1, action2Invocations.get()) + assertEquals(1, action3Invocations.get()) + } +} \ No newline at end of file