[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>interops</w>
<w>klibrary</w>
<w>undispatched</w>
</words>
</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.
*/
internal fun Project.launch(block: suspend KotlinPluginLifecycle.() -> Unit) {
kotlinPluginLifecycle.launch(block)
internal fun Project.launch(
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 {
object Success : ProjectConfigurationResult()
data class Failure(val failures: List<Throwable>) : ProjectConfigurationResult()
@@ -302,7 +317,10 @@ internal interface KotlinPluginLifecycle {
val isFinished: Boolean
fun launch(block: suspend KotlinPluginLifecycle.() -> Unit)
fun launch(
start: CoroutineStart = CoroutineStart.Default,
block: suspend KotlinPluginLifecycle.() -> Unit,
)
suspend fun await(stage: Stage)
@@ -6,6 +6,8 @@
package org.jetbrains.kotlin.gradle.plugin
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.utils.CompletableFuture
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 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()
})
enqueue(stage) {
coroutine.resume(Unit)
when (start) {
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.multiplatformExtensionOrNull
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.await
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
internal fun ProjectMetadataProvider(
sourceSetMetadataOutputs: Map<SourceSetName, SourceSetMetadataOutputs>
sourceSetMetadataOutputs: Map<SourceSetName, SourceSetMetadataOutputs>,
): ProjectMetadataProvider {
return ProjectMetadataProviderImpl(sourceSetMetadataOutputs)
}
internal class SourceSetMetadataOutputs(
val metadata: FileCollection?,
val cinterop: CInterop?
val cinterop: CInterop?,
) {
class CInterop(
val forCli: FileCollection,
val forIde: FileCollection
val forIde: FileCollection,
)
}
private class ProjectMetadataProviderImpl(
private val sourceSetMetadataOutputs: Map<SourceSetName, SourceSetMetadataOutputs>
private val sourceSetMetadataOutputs: Map<SourceSetName, SourceSetMetadataOutputs>,
) : ProjectMetadataProvider() {
override fun getSourceSetCompiledMetadata(sourceSetName: String): FileCollection? {
@@ -55,6 +56,19 @@ private class ProjectMetadataProviderImpl(
}
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 sourceSetMetadata = multiplatformExtension.sourceSetsMetadataOutputs()
@@ -83,7 +97,7 @@ private suspend fun KotlinMultiplatformExtension.sourceSetsMetadataOutputs(): Ma
}
private suspend fun KotlinMultiplatformExtension.cInteropMetadataOfSourceSets(
sourceSets: Iterable<KotlinSourceSet>
sourceSets: Iterable<KotlinSourceSet>,
): Map<KotlinSourceSet, SourceSetMetadataOutputs.CInterop?> {
val taskForCLI = project.commonizeCInteropTask() ?: return emptyMap()
val taskForIde = project.copyCommonizeCInteropForIdeTask() ?: return emptyMap()
@@ -95,4 +109,4 @@ private suspend fun KotlinMultiplatformExtension.cInteropMetadataOfSourceSets(
forIde = taskForIde.get().commonizedOutputLibraries(dependent)
)
}
}
}
@@ -8,6 +8,7 @@ package org.jetbrains.kotlin.gradle.utils
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.plugin.HasProject
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
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)
@@ -87,11 +91,14 @@ internal val <T> Future<T>.lenient: LenientFuture<T> get() = LenientFutureImpl(t
*
* 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 ->
launch { future.completeWith(runCatching { block() }) }
launch(start) { future.completeWith(runCatching { block() }) }
}
}
@@ -267,4 +274,4 @@ private class Completable<T>(
waitingContinuations.add(continuation)
}.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.serialize
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.Stage.FinaliseDsl
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.Stage.ReadyForExecution
@@ -150,4 +151,17 @@ class FutureTest {
future.await()
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
import org.gradle.api.ProjectConfigurationException
import org.gradle.internal.impldep.org.testng.TestException
import org.gradle.testfixtures.ProjectBuilder
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.Stage
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle.Stage.*
import org.jetbrains.kotlin.gradle.util.applyMultiplatformPlugin
import org.jetbrains.kotlin.gradle.util.buildProjectWithMPP
@@ -561,12 +559,133 @@ class KotlinPluginLifecycleTest {
fun `test - runLifecycleAwareTest - in a project with lifecycle`() {
val project = ProjectBuilder.builder().build()
project.applyMultiplatformPlugin()
project.runLifecycleAwareTest{}
project.runLifecycleAwareTest {}
}
@Test
fun `test - runLifecycleAwareTest - in a project without lifecycle`() {
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())
}
}