[Gradle] KotlinPluginLifecycle: Implement CoroutineStart and start futures 'Undispatched' by default

This API was inspired by kotlinx.coroutines and shall help
making Kotlin Gradle Plugins 'Future' type more applicable for situations
when the Future is used inside the Configuration Phase

^KT-60158 In Progress
This commit is contained in:
Sebastian Sellmair
2023-07-07 20:49:19 +02:00
committed by Space Team
parent 2d43476811
commit 42d648f775
7 changed files with 203 additions and 24 deletions
+2 -1
View File
@@ -7,6 +7,7 @@
<w>instantiator</w> <w>instantiator</w>
<w>interops</w> <w>interops</w>
<w>klibrary</w> <w>klibrary</w>
<w>undispatched</w>
</words> </words>
</dictionary> </dictionary>
</component> </component>
@@ -58,8 +58,11 @@ Util functions
* *
* If the lifecycle already finished and Gradle moved to its execution phase, then the block will be invoked right away. * If the lifecycle already finished and Gradle moved to its execution phase, then the block will be invoked right away.
*/ */
internal fun Project.launch(block: suspend KotlinPluginLifecycle.() -> Unit) { internal fun Project.launch(
kotlinPluginLifecycle.launch(block) start: CoroutineStart = CoroutineStart.Default,
block: suspend KotlinPluginLifecycle.() -> Unit,
) {
kotlinPluginLifecycle.launch(start, block)
} }
/** /**
@@ -289,6 +292,18 @@ internal interface KotlinPluginLifecycle {
} }
} }
enum class CoroutineStart {
/**
* Puts a coroutine at the end of the current execution queue
*/
Default,
/**
* Immediately executes the coroutine until its first suspension point in the current thread
*/
Undispatched
}
sealed class ProjectConfigurationResult { sealed class ProjectConfigurationResult {
object Success : ProjectConfigurationResult() object Success : ProjectConfigurationResult()
data class Failure(val failures: List<Throwable>) : ProjectConfigurationResult() data class Failure(val failures: List<Throwable>) : ProjectConfigurationResult()
@@ -302,7 +317,10 @@ internal interface KotlinPluginLifecycle {
val isFinished: Boolean val isFinished: Boolean
fun launch(block: suspend KotlinPluginLifecycle.() -> Unit) fun launch(
start: CoroutineStart = CoroutineStart.Default,
block: suspend KotlinPluginLifecycle.() -> Unit,
)
suspend fun await(stage: Stage) suspend fun await(stage: Stage)
@@ -6,6 +6,8 @@
package org.jetbrains.kotlin.gradle.plugin package org.jetbrains.kotlin.gradle.plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.CoroutineStart.Default
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.CoroutineStart.Undispatched
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.ProjectConfigurationResult import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.ProjectConfigurationResult
import org.jetbrains.kotlin.gradle.utils.CompletableFuture import org.jetbrains.kotlin.gradle.utils.CompletableFuture
import org.jetbrains.kotlin.gradle.utils.failures import org.jetbrains.kotlin.gradle.utils.failures
@@ -151,7 +153,10 @@ internal class KotlinPluginLifecycleImpl(override val project: Project) : Kotlin
} }
} }
override fun launch(block: suspend KotlinPluginLifecycle.() -> Unit) { override fun launch(
start: KotlinPluginLifecycle.CoroutineStart,
block: suspend KotlinPluginLifecycle.() -> Unit,
) {
val lifecycle = this val lifecycle = this
val coroutine = block.createCoroutine(this, object : Continuation<Unit> { val coroutine = block.createCoroutine(this, object : Continuation<Unit> {
@@ -161,8 +166,9 @@ internal class KotlinPluginLifecycleImpl(override val project: Project) : Kotlin
override fun resumeWith(result: Result<Unit>) = result.getOrThrow() override fun resumeWith(result: Result<Unit>) = result.getOrThrow()
}) })
enqueue(stage) { when (start) {
coroutine.resume(Unit) Default -> enqueue(stage) { coroutine.resume(Unit) }
Undispatched -> coroutine.resume(Unit)
} }
} }
@@ -10,6 +10,7 @@ import org.gradle.api.file.FileCollection
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.multiplatformExtensionOrNull import org.jetbrains.kotlin.gradle.dsl.multiplatformExtensionOrNull
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.Stage.AfterEvaluateBuildscript
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet
import org.jetbrains.kotlin.gradle.plugin.await import org.jetbrains.kotlin.gradle.plugin.await
import org.jetbrains.kotlin.gradle.plugin.mpp.MetadataDependencyResolution.ChooseVisibleSourceSets.MetadataProvider.ProjectMetadataProvider import org.jetbrains.kotlin.gradle.plugin.mpp.MetadataDependencyResolution.ChooseVisibleSourceSets.MetadataProvider.ProjectMetadataProvider
@@ -19,23 +20,23 @@ import org.jetbrains.kotlin.gradle.targets.native.internal.*
private typealias SourceSetName = String private typealias SourceSetName = String
internal fun ProjectMetadataProvider( internal fun ProjectMetadataProvider(
sourceSetMetadataOutputs: Map<SourceSetName, SourceSetMetadataOutputs> sourceSetMetadataOutputs: Map<SourceSetName, SourceSetMetadataOutputs>,
): ProjectMetadataProvider { ): ProjectMetadataProvider {
return ProjectMetadataProviderImpl(sourceSetMetadataOutputs) return ProjectMetadataProviderImpl(sourceSetMetadataOutputs)
} }
internal class SourceSetMetadataOutputs( internal class SourceSetMetadataOutputs(
val metadata: FileCollection?, val metadata: FileCollection?,
val cinterop: CInterop? val cinterop: CInterop?,
) { ) {
class CInterop( class CInterop(
val forCli: FileCollection, val forCli: FileCollection,
val forIde: FileCollection val forIde: FileCollection,
) )
} }
private class ProjectMetadataProviderImpl( private class ProjectMetadataProviderImpl(
private val sourceSetMetadataOutputs: Map<SourceSetName, SourceSetMetadataOutputs> private val sourceSetMetadataOutputs: Map<SourceSetName, SourceSetMetadataOutputs>,
) : ProjectMetadataProvider() { ) : ProjectMetadataProvider() {
override fun getSourceSetCompiledMetadata(sourceSetName: String): FileCollection? { override fun getSourceSetCompiledMetadata(sourceSetName: String): FileCollection? {
@@ -55,6 +56,19 @@ private class ProjectMetadataProviderImpl(
} }
internal suspend fun Project.collectSourceSetMetadataOutputs(): Map<SourceSetName, SourceSetMetadataOutputs> { internal suspend fun Project.collectSourceSetMetadataOutputs(): Map<SourceSetName, SourceSetMetadataOutputs> {
/*
Usually we can safely access the kotlin project extension inside a coroutine, as the Kotlin Gradle Plugin is
the only entity that could launch coroutines (hence the extension being available).
However, this code is crossing project boundaries here:
There is _some_ Kotlin Gradle plugin that requests the 'ProjectData' being collected for
*all* projects (breaking project isolation).
Therefore, it might happen that the Kotlin Plugin was not even applied at this point, when this
coroutine starts executing. We therefore await the wait for after the buildscript was evaluated to check
if the multiplatformExtension is present.
*/
AfterEvaluateBuildscript.await()
val multiplatformExtension = multiplatformExtensionOrNull ?: return emptyMap() val multiplatformExtension = multiplatformExtensionOrNull ?: return emptyMap()
val sourceSetMetadata = multiplatformExtension.sourceSetsMetadataOutputs() val sourceSetMetadata = multiplatformExtension.sourceSetsMetadataOutputs()
@@ -83,7 +97,7 @@ private suspend fun KotlinMultiplatformExtension.sourceSetsMetadataOutputs(): Ma
} }
private suspend fun KotlinMultiplatformExtension.cInteropMetadataOfSourceSets( private suspend fun KotlinMultiplatformExtension.cInteropMetadataOfSourceSets(
sourceSets: Iterable<KotlinSourceSet> sourceSets: Iterable<KotlinSourceSet>,
): Map<KotlinSourceSet, SourceSetMetadataOutputs.CInterop?> { ): Map<KotlinSourceSet, SourceSetMetadataOutputs.CInterop?> {
val taskForCLI = project.commonizeCInteropTask() ?: return emptyMap() val taskForCLI = project.commonizeCInteropTask() ?: return emptyMap()
val taskForIde = project.copyCommonizeCInteropForIdeTask() ?: return emptyMap() val taskForIde = project.copyCommonizeCInteropForIdeTask() ?: return emptyMap()
@@ -95,4 +109,4 @@ private suspend fun KotlinMultiplatformExtension.cInteropMetadataOfSourceSets(
forIde = taskForIde.get().commonizedOutputLibraries(dependent) forIde = taskForIde.get().commonizedOutputLibraries(dependent)
) )
} }
} }
@@ -8,6 +8,7 @@ package org.jetbrains.kotlin.gradle.utils
import org.gradle.api.Project import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.plugin.HasProject import org.jetbrains.kotlin.gradle.plugin.HasProject
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.CoroutineStart.Undispatched
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.IllegalLifecycleException import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.IllegalLifecycleException
import org.jetbrains.kotlin.gradle.plugin.kotlinPluginLifecycle import org.jetbrains.kotlin.gradle.plugin.kotlinPluginLifecycle
import org.jetbrains.kotlin.tooling.core.ExtrasLazyProperty import org.jetbrains.kotlin.tooling.core.ExtrasLazyProperty
@@ -75,7 +76,10 @@ internal inline fun <Receiver, reified T> futureExtension(
} }
} }
internal fun <T> Project.future(block: suspend Project.() -> T): Future<T> = kotlinPluginLifecycle.future { block() } internal fun <T> Project.future(
start: KotlinPluginLifecycle.CoroutineStart = Undispatched,
block: suspend Project.() -> T,
): Future<T> = kotlinPluginLifecycle.future(start) { block() }
internal val <T> Future<T>.lenient: LenientFuture<T> get() = LenientFutureImpl(this) internal val <T> Future<T>.lenient: LenientFuture<T> get() = LenientFutureImpl(this)
@@ -87,11 +91,14 @@ internal val <T> Future<T>.lenient: LenientFuture<T> get() = LenientFutureImpl(t
* *
* basically creating a future, which is launched lazily * basically creating a future, which is launched lazily
*/ */
internal fun <T> Project.lazyFuture(block: suspend Project.() -> T): Future<T> = LazyFutureImpl(lazy { future(block) }) internal fun <T> Project.lazyFuture(block: suspend Project.() -> T): Future<T> = LazyFutureImpl(lazy { future { block() } })
internal fun <T> KotlinPluginLifecycle.future(block: suspend () -> T): Future<T> { internal fun <T> KotlinPluginLifecycle.future(
start: KotlinPluginLifecycle.CoroutineStart = Undispatched,
block: suspend () -> T,
): Future<T> {
return FutureImpl<T>(lifecycle = this).also { future -> return FutureImpl<T>(lifecycle = this).also { future ->
launch { future.completeWith(runCatching { block() }) } launch(start) { future.completeWith(runCatching { block() }) }
} }
} }
@@ -267,4 +274,4 @@ private class Completable<T>(
waitingContinuations.add(continuation) waitingContinuations.add(continuation)
}.getOrThrow() }.getOrThrow()
} }
} }
@@ -10,6 +10,7 @@ package org.jetbrains.kotlin.gradle.unitTests
import org.jetbrains.kotlin.gradle.idea.testFixtures.utils.deserialize import org.jetbrains.kotlin.gradle.idea.testFixtures.utils.deserialize
import org.jetbrains.kotlin.gradle.idea.testFixtures.utils.serialize import org.jetbrains.kotlin.gradle.idea.testFixtures.utils.serialize
import org.jetbrains.kotlin.gradle.plugin.* import org.jetbrains.kotlin.gradle.plugin.*
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.CoroutineStart
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.IllegalLifecycleException import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.IllegalLifecycleException
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.Stage.FinaliseDsl import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.Stage.FinaliseDsl
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.Stage.ReadyForExecution import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.Stage.ReadyForExecution
@@ -150,4 +151,17 @@ class FutureTest {
future.await() future.await()
assertEquals(1, mappedFuture.getOrThrow()) assertEquals(1, mappedFuture.getOrThrow())
} }
@Test
fun `test - future is launched 'Undispatched' by default`() = project.runLifecycleAwareTest {
val future = project.future { }
future.getOrThrow()
}
@Test
fun `test - future with CoroutineStart 'Default'`() = project.runLifecycleAwareTest {
val future = project.future(CoroutineStart.Default) {}
assertFailsWith<IllegalLifecycleException> { future.getOrThrow() }
future.await()
}
} }
@@ -8,12 +8,10 @@
package org.jetbrains.kotlin.gradle.unitTests package org.jetbrains.kotlin.gradle.unitTests
import org.gradle.api.ProjectConfigurationException import org.gradle.api.ProjectConfigurationException
import org.gradle.internal.impldep.org.testng.TestException
import org.gradle.testfixtures.ProjectBuilder import org.gradle.testfixtures.ProjectBuilder
import org.jetbrains.kotlin.gradle.plugin.* import org.jetbrains.kotlin.gradle.plugin.*
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.IllegalLifecycleException import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.*
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.ProjectConfigurationResult.Failure 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.plugin.KotlinPluginLifecycle.Stage.*
import org.jetbrains.kotlin.gradle.util.applyMultiplatformPlugin import org.jetbrains.kotlin.gradle.util.applyMultiplatformPlugin
import org.jetbrains.kotlin.gradle.util.buildProjectWithMPP import org.jetbrains.kotlin.gradle.util.buildProjectWithMPP
@@ -561,12 +559,133 @@ class KotlinPluginLifecycleTest {
fun `test - runLifecycleAwareTest - in a project with lifecycle`() { fun `test - runLifecycleAwareTest - in a project with lifecycle`() {
val project = ProjectBuilder.builder().build() val project = ProjectBuilder.builder().build()
project.applyMultiplatformPlugin() project.applyMultiplatformPlugin()
project.runLifecycleAwareTest{} project.runLifecycleAwareTest {}
} }
@Test @Test
fun `test - runLifecycleAwareTest - in a project without lifecycle`() { fun `test - runLifecycleAwareTest - in a project without lifecycle`() {
val project = ProjectBuilder.builder().build() val project = ProjectBuilder.builder().build()
assertFailsWith<AssertionError> { project.runLifecycleAwareTest{} } assertFailsWith<AssertionError> { project.runLifecycleAwareTest {} }
} }
}
@Test
fun `test - launch with CoroutineStart - Default`() = project.runLifecycleAwareTest {
val rootCoroutineExecuted = AtomicBoolean()
val coroutineAExecuted = AtomicBoolean()
val coroutineBExecuted = AtomicBoolean()
launch(CoroutineStart.Default) coroutineA@{
/*
This 'coroutine A' is launched using 'Default' which means that it will
be 'dispatched' by being placed at the back of the queue.
The currently executing 'root coroutine' therefore will finish.
Only if the previous queue is empty, this coroutine will start execution
*/
assertTrue(
rootCoroutineExecuted.get(),
"Expected 'root coroutine' to have executed"
)
assertFalse(
coroutineBExecuted.get(),
"Expected 'coroutineA' being executed before 'coroutineB'"
)
assertFalse(coroutineAExecuted.getAndSet(true))
}
launch(CoroutineStart.Default) coroutineB@{
/*
See code comment above: This 'coroutineB' is put at the end of the current execution queue
*/
assertTrue(
rootCoroutineExecuted.get(),
"Expected 'root coroutine' to have executed"
)
assertTrue(
coroutineAExecuted.get(),
"Expected 'coroutineA' being executed before 'coroutineB'"
)
assertFalse(coroutineBExecuted.getAndSet(true))
}
assertFalse(rootCoroutineExecuted.getAndSet(true))
assertFalse(
coroutineAExecuted.get(),
"Expected 'coroutineA' to not be executed yet"
)
assertFalse(
coroutineBExecuted.get(),
"Expected 'coroutineB' to not be executed yet"
)
launch(CoroutineStart.Default) {
assertTrue(
coroutineAExecuted.get(),
"Expected 'coroutineA' to be executed"
)
assertTrue(
coroutineBExecuted.get(),
"Expected 'coroutineB' to be executed"
)
}
}
@Test
fun `test - launch with CoroutineStart - Undispatched`() = project.runLifecycleAwareTest {
val rootCoroutineFinished = AtomicBoolean()
val coroutineAExecuted = AtomicBoolean()
val coroutineBExecuted = AtomicBoolean()
launch(CoroutineStart.Undispatched) coroutineA@{
/*
Undispatched will start executing the coroutine 'inline' and will
only suspend once the first 'suspension' point is hit.
This means, that at this point, the 'root coroutine' had no chance to finsih yet.
*/
assertFalse(
rootCoroutineFinished.get(),
"Expected 'root coroutine' to not have finished yet"
)
assertFalse(
coroutineBExecuted.get(),
"Expected 'coroutineA' to be executed before 'coroutineB'"
)
assertFalse(coroutineAExecuted.getAndSet(true))
}
assertTrue(
coroutineAExecuted.get(),
"Expected 'coroutineA' to be executed 'inline'"
)
launch(CoroutineStart.Undispatched) coroutineB@{
/*
See comment in coroutineA
*/
assertFalse(
rootCoroutineFinished.get(),
"Expected 'root coroutine' to not have finished yet"
)
assertTrue(
coroutineAExecuted.get(),
"Expected 'coroutineA' to be executed before 'coroutineB'"
)
assertFalse(coroutineBExecuted.getAndSet(true))
}
assertFalse(rootCoroutineFinished.getAndSet(true))
assertTrue(coroutineAExecuted.get())
assertTrue(coroutineBExecuted.get())
}
}