diff --git a/.idea/dictionaries/sebastiansellmair.xml b/.idea/dictionaries/sebastiansellmair.xml index 75a2abae6dc..6185db2e3b8 100644 --- a/.idea/dictionaries/sebastiansellmair.xml +++ b/.idea/dictionaries/sebastiansellmair.xml @@ -7,6 +7,7 @@ instantiator interops klibrary + undispatched - \ No newline at end of file + 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 3acdf1a8850..82dcf017328 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 @@ -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) : 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) diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginLifecycleImpl.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginLifecycleImpl.kt index f2863a20c37..d0de393a0b4 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginLifecycleImpl.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginLifecycleImpl.kt @@ -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 { @@ -161,8 +166,9 @@ internal class KotlinPluginLifecycleImpl(override val project: Project) : Kotlin override fun resumeWith(result: Result) = result.getOrThrow() }) - enqueue(stage) { - coroutine.resume(Unit) + when (start) { + Default -> enqueue(stage) { coroutine.resume(Unit) } + Undispatched -> coroutine.resume(Unit) } } diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/ProjectMetadataProviderImpl.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/ProjectMetadataProviderImpl.kt index c477a4195c9..e7dd4d9a123 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/ProjectMetadataProviderImpl.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/ProjectMetadataProviderImpl.kt @@ -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 + sourceSetMetadataOutputs: Map, ): 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 + private val sourceSetMetadataOutputs: Map, ) : ProjectMetadataProvider() { override fun getSourceSetCompiledMetadata(sourceSetName: String): FileCollection? { @@ -55,6 +56,19 @@ private class ProjectMetadataProviderImpl( } internal suspend fun Project.collectSourceSetMetadataOutputs(): Map { + /* + 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 + sourceSets: Iterable, ): Map { 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) ) } -} \ No newline at end of file +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/utils/Future.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/utils/Future.kt index aa29237c658..20858e94b0c 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/utils/Future.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/utils/Future.kt @@ -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 futureExtension( } } -internal fun Project.future(block: suspend Project.() -> T): Future = kotlinPluginLifecycle.future { block() } +internal fun Project.future( + start: KotlinPluginLifecycle.CoroutineStart = Undispatched, + block: suspend Project.() -> T, +): Future = kotlinPluginLifecycle.future(start) { block() } internal val Future.lenient: LenientFuture get() = LenientFutureImpl(this) @@ -87,11 +91,14 @@ internal val Future.lenient: LenientFuture get() = LenientFutureImpl(t * * basically creating a future, which is launched lazily */ -internal fun Project.lazyFuture(block: suspend Project.() -> T): Future = LazyFutureImpl(lazy { future(block) }) +internal fun Project.lazyFuture(block: suspend Project.() -> T): Future = LazyFutureImpl(lazy { future { block() } }) -internal fun KotlinPluginLifecycle.future(block: suspend () -> T): Future { +internal fun KotlinPluginLifecycle.future( + start: KotlinPluginLifecycle.CoroutineStart = Undispatched, + block: suspend () -> T, +): Future { return FutureImpl(lifecycle = this).also { future -> - launch { future.completeWith(runCatching { block() }) } + launch(start) { future.completeWith(runCatching { block() }) } } } @@ -267,4 +274,4 @@ private class Completable( waitingContinuations.add(continuation) }.getOrThrow() } -} \ No newline at end of file +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/FutureTest.kt b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/FutureTest.kt index 144d4130856..5c029785574 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/FutureTest.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/FutureTest.kt @@ -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 { future.getOrThrow() } + future.await() + } } 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 261f9d654c1..10a8bf92a59 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 @@ -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 { project.runLifecycleAwareTest{} } + assertFailsWith { project.runLifecycleAwareTest {} } } -} \ No newline at end of file + + @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()) + } + +}