fix(tooling-api): handle failures related to project directory

This commit is contained in:
Akash Yadav
2023-11-06 23:20:57 +05:30
parent 43907984e5
commit e985d2ac4d
7 changed files with 137 additions and 22 deletions
@@ -50,6 +50,10 @@ import com.itsaky.androidide.tasks.executeWithProgress
import com.itsaky.androidide.tooling.api.messages.AndroidInitializationParams
import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams
import com.itsaky.androidide.tooling.api.messages.result.InitializeResult
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_DIRECTORY_INACCESSIBLE
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_DIRECTORY
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_FOUND
import com.itsaky.androidide.tooling.api.models.BuildVariantInfo
import com.itsaky.androidide.tooling.api.models.mapToSelectedVariants
import com.itsaky.androidide.utils.DURATION_INDEFINITE
@@ -394,7 +398,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() {
if (result == null || !result.isSuccessful || error != null) {
log.error("An error occurred initializing the project with Tooling API", error)
ThreadUtils.runOnUiThread {
postProjectInit(false)
postProjectInit(false, result?.failure)
}
return@whenCompleteAsync
}
@@ -464,7 +468,9 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() {
manager.notifyProjectUpdate()
updateBuildVariants(manager.androidBuildVariants)
com.itsaky.androidide.tasks.runOnUiThread { postProjectInit(true) }
com.itsaky.androidide.tasks.runOnUiThread {
postProjectInit(true, null)
}
}
}
@@ -473,11 +479,23 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() {
editorViewModel.isInitializing = true
}
protected open fun postProjectInit(isSuccessful: Boolean) {
protected open fun postProjectInit(isSuccessful: Boolean, failure: TaskExecutionResult.Failure?) {
val manager = ProjectManagerImpl.getInstance()
if (!isSuccessful) {
setStatus(getString(string.msg_project_initialization_failed))
flashError(string.msg_project_initialization_failed)
val initFailed = getString(string.msg_project_initialization_failed)
setStatus(initFailed)
val msg = when(failure) {
PROJECT_DIRECTORY_INACCESSIBLE -> string.msg_project_dir_inaccessible
PROJECT_NOT_DIRECTORY -> string.msg_file_is_not_dir
PROJECT_NOT_FOUND -> string.msg_project_dir_doesnt_exist
else -> null
}?.let {
"$initFailed: ${getString(it)}"
}
flashError(msg ?: initFailed)
editorViewModel.isInitializing = false
manager.projectInitialized = false
return
@@ -622,4 +622,7 @@
<string name="idepref_general_localeSelector_title">Language</string>
<string name="idepref_general_localeSelector_summary">Select a language for AndroidIDE.</string>
<string name="locale_system_default">System Default</string>
<string name="msg_project_dir_inaccessible">Project directory is not accessible</string>
<string name="msg_file_is_not_dir">Selected file is not a directory</string>
<string name="msg_project_dir_doesnt_exist">Project directory does not exist</string>
</resources>
@@ -219,6 +219,10 @@ class ProjectManagerImpl : IProjectManager, EventReceiver {
return
}
if (builder.isBuildInProgress) {
return
}
val tasks = getAndroidModules().flatMap { module ->
val variant = module.getSelectedVariant()
if (variant == null) {
@@ -35,6 +35,10 @@ import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Fai
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.BUILD_FAILED
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.CONNECTION_CLOSED
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.CONNECTION_ERROR
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_DIRECTORY_INACCESSIBLE
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_DIRECTORY
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_FOUND
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.PROJECT_NOT_INITIALIZED
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.UNKNOWN
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.UNSUPPORTED_BUILD_ARGUMENT
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult.Failure.UNSUPPORTED_CONFIGURATION
@@ -57,7 +61,6 @@ import org.gradle.tooling.internal.consumer.DefaultGradleConnector
import java.io.File
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.system.exitProcess
/**
@@ -68,7 +71,6 @@ import kotlin.system.exitProcess
internal class ToolingApiServerImpl(private val project: ProjectImpl) :
IToolingApiServer {
private val _isInitialized = AtomicBoolean(false)
private var client: IToolingApiClient? = null
private var connector: GradleConnector? = null
private var connection: ProjectConnection? = null
@@ -79,9 +81,13 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) :
/**
* Whether the project has been initialized or not.
*/
var isInitialized: Boolean
get() = _isInitialized.get()
private set(value) = _isInitialized.set(value)
var isInitialized: Boolean = false
private set
/**
* Whether a build or project synchronization is in progress.
*/
private var isBuildInProgress: Boolean = false
/**
* Whether the server has a live connection to Gradle.
@@ -102,7 +108,7 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) :
}
override fun initialize(params: InitializeProjectParams): CompletableFuture<InitializeResult> {
return CompletableFuture.supplyAsync {
return runBuild {
try {
log.debug("Got initialize request", params)
@@ -112,6 +118,14 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) :
cancelCurrentBuild().get()
}
val projectDirectory = File(params.directory)
val failureReason = validateProjectDirectory(projectDirectory)
if (failureReason != null) {
log.error("Cannot initialize project: $failureReason")
return@runBuild InitializeResult(false, failureReason)
}
val stopWatch = StopWatch("Connection to project")
val isReinitializing = connector != null && connection != null && params == lastInitParams
@@ -123,7 +137,7 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) :
// or the project is being initialized with different parameters
connector?.disconnect()
connector = GradleConnector.newConnector().forProjectDirectory(File(params.directory))
connector = GradleConnector.newConnector().forProjectDirectory(projectDirectory)
setupConnectorForGradleInstallation(this.connector!!, params.gradleDistribution)
stopWatch.lap("Connector created")
}
@@ -163,15 +177,24 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) :
this.isInitialized = true
notifyBuildSuccess(emptyList())
return@supplyAsync InitializeResult(true)
return@runBuild InitializeResult(true)
} catch (err: Throwable) {
log.error("Failed to initialize project", err)
notifyBuildFailure(emptyList())
return@supplyAsync InitializeResult(false)
return@runBuild InitializeResult(false, getTaskFailureType(err))
}
}
}
private fun validateProjectDirectory(
projectDirectory: File
) = when {
!projectDirectory.exists() -> PROJECT_NOT_FOUND
!projectDirectory.isDirectory -> PROJECT_NOT_DIRECTORY
!projectDirectory.canRead() -> PROJECT_DIRECTORY_INACCESSIBLE
else -> null
}
override fun isServerInitialized(): CompletableFuture<Boolean> {
return CompletableFuture.supplyAsync { isInitialized }
}
@@ -184,8 +207,21 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) :
}
override fun executeTasks(message: TaskExecutionMessage): CompletableFuture<TaskExecutionResult> {
return CompletableFuture.supplyAsync {
assertProjectInitialized()
return runBuild {
if (!isServerInitialized().get()) {
log.error("Cannot execute tasks: $PROJECT_NOT_INITIALIZED")
return@runBuild TaskExecutionResult(false, PROJECT_NOT_INITIALIZED)
}
val lastInitParams = this.lastInitParams
if (lastInitParams != null) {
val projectDirectory = File(lastInitParams.directory)
val failureReason = validateProjectDirectory(projectDirectory)
if (failureReason != null) {
log.error("Cannot execute tasks: $failureReason")
return@runBuild TaskExecutionResult(isSuccessful = false, failureReason)
}
}
log.debug("Received request to run tasks.", message)
@@ -215,10 +251,10 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) :
builder.run()
this.buildCancellationToken = null
notifyBuildSuccess(message.tasks)
return@supplyAsync TaskExecutionResult.SUCCESS
return@runBuild TaskExecutionResult.SUCCESS
} catch (error: Throwable) {
notifyBuildFailure(message.tasks)
return@supplyAsync TaskExecutionResult(false, getTaskFailureType(error), error.message)
return@runBuild TaskExecutionResult(false, getTaskFailureType(error))
}
}
}
@@ -330,6 +366,26 @@ internal class ToolingApiServerImpl(private val project: ProjectImpl) :
else -> UNKNOWN
}
private inline fun <T : Any?> supplyAsync(crossinline action: () -> T): CompletableFuture<T> =
CompletableFuture.supplyAsync {
action()
}
private inline fun <T : Any?> runBuild(crossinline action: () -> T): CompletableFuture<T> =
supplyAsync {
if (isBuildInProgress) {
log.error("Cannot run build, build is already in prorgess!")
throw IllegalStateException("Build is already in progress")
}
isBuildInProgress = true
try {
action()
} finally {
isBuildInProgress = false
}
}
private fun assertProjectInitialized() {
if (!isServerInitialized().get()) {
throw CompletionException(IllegalStateException("Project is not initialized!"))
@@ -28,6 +28,7 @@ import com.itsaky.androidide.tooling.api.IProject
import com.itsaky.androidide.tooling.api.IToolingApiServer
import com.itsaky.androidide.tooling.api.ProjectType
import com.itsaky.androidide.tooling.api.messages.TaskExecutionMessage
import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult
import com.itsaky.androidide.tooling.api.models.AndroidProjectMetadata
import com.itsaky.androidide.tooling.api.models.JavaModuleExternalDependency
import com.itsaky.androidide.tooling.api.models.JavaModuleProjectDependency
@@ -37,11 +38,22 @@ import com.itsaky.androidide.tooling.events.ProgressEvent
import com.itsaky.androidide.tooling.events.task.TaskStartEvent
import com.itsaky.androidide.tooling.testing.ToolingApiTestLauncher
import com.itsaky.androidide.tooling.testing.ToolingApiTestLauncher.MultiVersionTestClient
import com.itsaky.androidide.utils.FileProvider
import com.itsaky.androidide.utils.ILogger
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import java.nio.file.Paths
import java.nio.file.attribute.PosixFilePermission
import java.util.concurrent.CompletableFuture
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.createDirectories
import kotlin.io.path.createFile
import kotlin.io.path.deleteIfExists
import kotlin.io.path.deleteRecursively
import kotlin.io.path.exists
import kotlin.io.path.setPosixFilePermissions
import kotlin.io.path.writeText
/** @author Akash Yadav */
@RunWith(JUnit4::class)
@@ -54,6 +66,26 @@ class MultiModuleAndroidProjectTest {
doAssertions(project, server)
}
@Test
fun `test non-existing project initialization`() {
val (_, _, result) = ToolingApiTestLauncher().launchServer(
projectDir = Paths.get("/directory/does/not/exist/"))
assertThat(result?.isSuccessful).isFalse()
assertThat(result?.failure).isEqualTo(TaskExecutionResult.Failure.PROJECT_NOT_FOUND)
}
@Test
fun `test project initialization with file path`() {
val path = FileProvider.projectRoot().resolve("build").resolve("should-be-directory.txt")
path.deleteIfExists()
path.createFile()
val (_, _, result) = ToolingApiTestLauncher().launchServer(
projectDir = path)
assertThat(result?.isSuccessful).isFalse()
assertThat(result?.failure).isEqualTo(TaskExecutionResult.Failure.PROJECT_NOT_DIRECTORY)
}
@Test
fun `test task name should be shown if gradle configuration cache is enabled`() {
@@ -23,4 +23,4 @@ package com.itsaky.androidide.tooling.api.messages.result
* @param isSuccessful Whether the project initialization was successful.
* @author Akash Yadav
*/
class InitializeResult(val isSuccessful: Boolean)
class InitializeResult(val isSuccessful: Boolean, val failure: TaskExecutionResult.Failure? = TaskExecutionResult.Failure.UNKNOWN)
@@ -22,11 +22,10 @@ package com.itsaky.androidide.tooling.api.messages.result
*
* @param isSuccessful The result of the task execution.
* @param failure The type of failure. Non-null only if [isSuccessful] is `false`.
* @param failureMessage The message describing the failure.
*
* @author Akash Yadav
*/
data class TaskExecutionResult(val isSuccessful: Boolean, val failure: Failure?, val failureMessage: String?) {
data class TaskExecutionResult(val isSuccessful: Boolean, val failure: Failure?) {
companion object {
@@ -34,11 +33,14 @@ data class TaskExecutionResult(val isSuccessful: Boolean, val failure: Failure?,
* Result for a successful build.
*/
@JvmStatic
val SUCCESS = TaskExecutionResult(true, null, null)
val SUCCESS = TaskExecutionResult(true, null)
}
enum class Failure {
PROJECT_NOT_FOUND,
PROJECT_NOT_INITIALIZED,
PROJECT_NOT_DIRECTORY,
PROJECT_DIRECTORY_INACCESSIBLE,
UNKNOWN,
UNSUPPORTED_GRADLE_VERSION,
UNSUPPORTED_CONFIGURATION,