Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MBS-12612 Detect and prevent Kotlin daemon fallback strategy #1326

Merged
merged 1 commit into from
Feb 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/content/projects/BuildChecks.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,39 @@ This check automatically detects the issue:
}
```

#### Prevent Kotlin daemon fallback strategy

Sometimes Kotlin daemon fails and can't recover on its own.
It has incredible impact on build performance and continuing build is not worth it.
This is a workaround for [KT-48843](https://youtrack.jetbrains.com/issue/KT-48843). See there more details.

=== "Kotlin"
`build.gradle.kts`

```kotlin
buildChecks {
preventKotlinDaemonFallback {
enabled = true // disabled by default
}
}
```

=== "Groovy"
`build.gradle`

```groovy
buildChecks {
preventKotlinDaemonFallback {
enabled = true // disabled by default
}
}
```

#### Gradle properties

???+ warning
This check is deprecated and will be removed.

--8<--
avito-disclaimer.md
--8<--
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ internal class ConfigurationCacheCompatibilityTest {
javaVersion {
version = org.gradle.api.JavaVersion.VERSION_11
}
preventKotlinDaemonFallback {
enabled = true
}
}
""".trimIndent()
).generateIn(projectDir)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.avito.android.build_checks.kotlin_daemon

import com.avito.test.gradle.TestProjectGenerator
import com.avito.test.gradle.gradlew
import com.avito.test.gradle.module.KotlinModule
import com.avito.test.gradle.plugin.plugins
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File

internal class PreventKotlinDaemonFallbackTest {

private lateinit var projectDir: File

@BeforeEach
fun setup(@TempDir tempDir: File) {
this.projectDir = tempDir
}

@Test
fun `disabled check - uses fallback strategy`() {
generateProject(enableCheck = false)
val result = build(":lib1:compileKotlin")

result.assertThat()
.buildSuccessful()
.outputContains("Could not connect to kotlin daemon. Using fallback strategy.")
}

@Test
fun `single fallback - success`() {
generateProject(enableCheck = true)
val result = build(":lib1:compileKotlin")

result.assertThat()
.buildSuccessful()
}

@Test
fun `multiple fallbacks - failure`() {
generateProject(enableCheck = true)
val result = build("compileKotlin", expectFailure = true)

result.assertThat()
.buildFailed()
.outputContains("Kill Kotlin daemon")
}

private fun generateProject(enableCheck: Boolean) {
TestProjectGenerator(
plugins = plugins {
id("com.avito.android.build-checks")
},
modules = listOf(
KotlinModule(name = "lib1"),
KotlinModule(name = "lib2"),
),
buildGradleExtra = """
buildChecks {
enableByDefault = false
preventKotlinDaemonFallback {
enabled = $enableCheck
}
}
""".trimIndent()
).generateIn(projectDir)
}

private fun build(
task: String,
expectFailure: Boolean = false
) = gradlew(
projectDir,
task,
"-Dkotlin.daemon.jvm.options=invalid_jvm_argument_to_fail_process_startup",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda like this hacky approach )
but there is a chance that it will be handled differently in the future, just saying

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, I understand and accept this trade-off.
I haven't found other good ways to simulate this but having tests is important.

"--rerun-tasks",
expectFailure = expectFailure
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.avito.android.build_checks.internal.BuildEnvironmentInfo
import com.avito.android.build_checks.internal.CheckAndroidSdkVersionTask
import com.avito.android.build_checks.internal.MacOSLocalhostResolvingTask
import com.avito.android.build_checks.internal.RootTaskCreator
import com.avito.android.build_checks.internal.kotlin_daemon.KotlinDaemonFallbackDetector
import com.avito.android.build_checks.internal.params.GradlePropertiesChecker
import com.avito.android.build_checks.internal.unique_app_res.UniqueAppResourcesTaskCreator
import com.avito.android.build_checks.internal.unique_r.UniqueRClassesTaskCreator
Expand Down Expand Up @@ -75,6 +76,9 @@ public open class BuildParamCheckPlugin : Plugin<Project> {
if (checks.hasInstance<RootProjectCheck.GradleProperties>()) {
GradlePropertiesChecker(project, envInfo).check()
}
if (checks.hasInstance<RootProjectCheck.PreventKotlinDaemonFallback>()) {
KotlinDaemonFallbackDetector().register(project)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.avito.android.build_checks.RootProjectChecksExtension.RootProjectChec
import com.avito.android.build_checks.RootProjectChecksExtension.RootProjectCheck.GradleProperties
import com.avito.android.build_checks.RootProjectChecksExtension.RootProjectCheck.JavaVersion
import com.avito.android.build_checks.RootProjectChecksExtension.RootProjectCheck.MacOSLocalhost
import com.avito.android.build_checks.RootProjectChecksExtension.RootProjectCheck.PreventKotlinDaemonFallback
import org.gradle.api.Action
import kotlin.reflect.full.createInstance

Expand All @@ -27,6 +28,9 @@ public open class RootProjectChecksExtension : BuildChecksExtension() {
public fun gradleProperties(action: Action<GradleProperties>): Unit =
register(GradleProperties(), action)

public fun preventKotlinDaemonFallback(action: Action<PreventKotlinDaemonFallback>): Unit =
register(PreventKotlinDaemonFallback(), action)

public sealed class RootProjectCheck : Check {

public override var enabled: Boolean = true
Expand Down Expand Up @@ -56,6 +60,10 @@ public open class RootProjectChecksExtension : BuildChecksExtension() {
override var enabled: Boolean = false
}

public open class PreventKotlinDaemonFallback : RootProjectCheck() {
override var enabled: Boolean = false
}

override fun equals(other: Any?): Boolean {
return this.javaClass == other?.javaClass
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ ERROR: '$checkExtensionName' build check is failed.

${message.trimIndent()}

This check can be disabled in an extension:
This check can be skipped in an extension:
$extensionName {
$checkExtensionName {
enabled = false
enabled = ...
}
}
See https://avito-tech.github.io/avito-android/projects/BuildChecks (search '$checkExtensionName')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.avito.android.build_checks.internal.kotlin_daemon

import com.avito.android.build_checks.RootProjectChecksExtension
import com.avito.android.build_checks.internal.FailedCheckMessage
import org.gradle.api.GradleException
import org.gradle.api.internal.GradleInternal
import org.gradle.api.internal.tasks.execution.ExecuteTaskBuildOperationType
import org.gradle.api.invocation.Gradle
import org.gradle.execution.RunRootBuildWorkBuildOperationType
import org.gradle.internal.operations.BuildOperationCategory
import org.gradle.internal.operations.BuildOperationDescriptor
import org.gradle.internal.operations.BuildOperationListener
import org.gradle.internal.operations.BuildOperationListenerManager
import org.gradle.internal.operations.OperationFinishEvent
import org.gradle.internal.operations.OperationIdentifier
import org.gradle.internal.operations.OperationProgressEvent
import org.gradle.internal.operations.OperationStartEvent
import java.util.concurrent.atomic.AtomicInteger

internal class BuildFailer(
private val fallbackCounter: AtomicInteger,
) : BuildOperationListener {

var cleanupAction: (() -> Unit)? = null

override fun started(buildOperation: BuildOperationDescriptor, event: OperationStartEvent) {
}

override fun progress(operationIdentifier: OperationIdentifier, progressEvent: OperationProgressEvent) {
}

override fun finished(buildOperation: BuildOperationDescriptor, event: OperationFinishEvent) {
if (isTaskFinished(event) && fallbackCounter.get() > FALLBACKS_COUNT_THRESHOLD) {
doCleanup()
failBuild()
}
if (isBuildFinished(buildOperation)) {
doCleanup()
}
}

@Suppress("MaxLineLength")
private fun failBuild() {
throw GradleException(
FailedCheckMessage(
RootProjectChecksExtension::preventKotlinDaemonFallback,
"""
|Kotlin daemon process is not available and most probably won't recover on its own.
|It has incredible impact on build performance and continuing build is not worth it.
|https://youtrack.jetbrains.com/issue/KT-48843
|
|How to fix
|
|Kill Kotlin daemon process:
| 1. ./gradlew --stop
| 2. Find Kotlin daemon process id (pid): `jps | grep Kotlin`
| 3. kill <pid>
|
|If it doesn't help, check that there are no custom jvm arguments in "kotlin.daemon.jvm.options" property except for Xmx.
""".trimMargin()
).toString()
)
}

private fun doCleanup() {
val action = requireNotNull(cleanupAction) {
"cleanupAction must be set to unsubscribe Gradle listeners"
}
action()
}

private fun isTaskFinished(event: OperationFinishEvent): Boolean =
event.result is ExecuteTaskBuildOperationType.Result

private fun isBuildFinished(operation: BuildOperationDescriptor): Boolean =
operation.details is RunRootBuildWorkBuildOperationType.Details
&& operation.metadata == BuildOperationCategory.RUN_WORK
}

/**
* To be more confident that daemon process is non recoverable
*/
private const val FALLBACKS_COUNT_THRESHOLD = 1

internal fun Gradle.buildOperationListenerManager(): BuildOperationListenerManager =
(this as GradleInternal).services[BuildOperationListenerManager::class.java]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.avito.android.build_checks.internal.kotlin_daemon

import org.gradle.internal.logging.events.LogEvent
import org.gradle.internal.logging.events.OutputEvent
import org.gradle.internal.logging.events.OutputEventListener
import java.util.concurrent.atomic.AtomicInteger

internal class FailureEventListener(
private val fallbacksCounter: AtomicInteger,
) : OutputEventListener {

override fun onOutput(event: OutputEvent) {
if (isFallbackMessage(event)) {
// Can't fail a build from OutputEventListener. So, only mark it
fallbacksCounter.incrementAndGet()
}
}

private fun isFallbackMessage(event: OutputEvent): Boolean {
return event is LogEvent
&& event.message.contains("Could not connect to kotlin daemon. Using fallback strategy.")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.avito.android.build_checks.internal.kotlin_daemon

import org.gradle.api.Project
import org.gradle.internal.logging.LoggingManagerInternal
import org.gradle.kotlin.dsl.support.serviceOf
import java.util.concurrent.atomic.AtomicInteger

internal class KotlinDaemonFallbackDetector {

fun register(project: Project) {
if (isDaemonDisabled(project)) {
project.logger.debug("Kotlin daemon fallback detection is disabled due to absence of daemon")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lifecycle?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #1336

return
}
val fallbacksCounter = AtomicInteger(0)

val loggingManager = project.gradle.serviceOf<LoggingManagerInternal>()
val listenerManager = project.gradle.buildOperationListenerManager()

val failureListener = FailureEventListener(fallbacksCounter)
loggingManager.addOutputEventListener(failureListener)

val buildFailer = BuildFailer(fallbacksCounter)
buildFailer.cleanupAction = {
loggingManager.removeOutputEventListener(failureListener)
listenerManager.removeListener(buildFailer)
}
listenerManager.addListener(buildFailer)
}

/**
* Copy of internal logic in GradleKotlinCompilerRunner
*/
private fun isDaemonDisabled(project: Project): Boolean {
val strategy = project.providers.systemProperty("kotlin.compiler.execution.strategy")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be broken if property name will be changed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test to this case: #1336

.forUseAtConfigurationTime()
.getOrElse("daemon")
return strategy != "daemon" // "in-process", "out-of-process"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add to check flag to force using the deamon

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the need for it yet.
Daemon is the default behavior for a long time. Also, we don't have proof that it's better than other strategies.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.avito.android.stats.SeriesName
import com.avito.android.stats.statsd
import org.gradle.api.Project

// TODO: Will be removed in MBS-12691
internal class GradlePropertiesChecker(
private val project: Project,
private val envInfo: BuildEnvironmentInfo
Expand Down