From 8cf2d0936fec526df2400a23b35c71924dcfe513 Mon Sep 17 00:00:00 2001 From: Karn Saheb Date: Sat, 14 Feb 2026 10:50:44 -0800 Subject: [PATCH] Fix Gradle configuration cache support (#114) Move all Project access out of @TaskAction methods into task properties set at configuration time. This enables Gradle's configuration cache by ensuring tasks don't reference the Project graph during execution. - Make CargoBuildTask and GenerateToolchainsTask abstract with injected ExecOperations and FileSystemOperations services - Add @Input/@Internal properties for all values previously read from CargoExtension and Project at execution time - Make Ndk, Toolchain, Features, and FeatureSpec implement Serializable - Warn when cargo.exec closure is set (not compatible with config cache) --- .../kotlin/com/nishtahir/CargoBuildTask.kt | 272 ++++++++++-------- .../kotlin/com/nishtahir/CargoExtension.kt | 4 +- .../com/nishtahir/GenerateToolchainsTask.kt | 52 ++-- .../kotlin/com/nishtahir/RustAndroidPlugin.kt | 40 ++- 4 files changed, 227 insertions(+), 141 deletions(-) diff --git a/plugin/src/main/kotlin/com/nishtahir/CargoBuildTask.kt b/plugin/src/main/kotlin/com/nishtahir/CargoBuildTask.kt index 363f0ec4..57acd730 100644 --- a/plugin/src/main/kotlin/com/nishtahir/CargoBuildTask.kt +++ b/plugin/src/main/kotlin/com/nishtahir/CargoBuildTask.kt @@ -1,120 +1,139 @@ package com.nishtahir; -import com.android.build.gradle.* import org.apache.tools.ant.taskdefs.condition.Os import org.gradle.api.DefaultTask import org.gradle.api.GradleException -import org.gradle.api.Project import org.gradle.api.logging.LogLevel import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.gradle.api.file.FileSystemOperations +import org.gradle.process.ExecSpec import java.io.ByteArrayOutputStream import java.io.File +import javax.inject.Inject + +abstract class CargoBuildTask : DefaultTask() { + @get:Inject + abstract val execOperations: ExecOperations + + @get:Inject + abstract val fileSystemOperations: FileSystemOperations -open class CargoBuildTask : DefaultTask() { @Input var toolchain: Toolchain? = null @Input var ndk: Ndk? = null - @Suppress("unused") - @TaskAction - fun build() = with(project) { - extensions[CargoExtension::class].apply { - // Need to capture the value to dereference smoothly. - val toolchain = toolchain - if (toolchain == null) { - throw GradleException("toolchain cannot be null") - } + @Internal + var projectDir: File = File("") - val ndk = ndk ?: throw GradleException("ndk cannot be null") + @Internal + var buildDir: File = File("") - project.plugins.all { - when (it) { - is AppPlugin -> buildProjectForTarget(project, toolchain, ndk, this) - is LibraryPlugin -> buildProjectForTarget(project, toolchain, ndk, this) - } - } - // CARGO_TARGET_DIR can be used to force the use of a global, shared target directory - // across all rust projects on a machine. Use it if it's set, otherwise use the - // configured `targetDirectory` value, and fall back to `${module}/target`. - // - // We also allow this to be specified in `local.properties`, not because this is - // something you should ever need to do currently, but we don't want it to ruin anyone's - // day if it turns out we're wrong about that. - val target = - getProperty("rust.cargoTargetDir", "CARGO_TARGET_DIR") - ?: targetDirectory - ?: "${module!!}/target" - - val defaultTargetTriple = getDefaultTargetTriple(project, rustcCommand) - - var cargoOutputDir = File(if (toolchain.target == defaultTargetTriple) { - "${target}/${profile}" - } else { - "${target}/${toolchain.target}/${profile}" - }) - if (!cargoOutputDir.isAbsolute) { - cargoOutputDir = File(project.project.projectDir, cargoOutputDir.path) - } - cargoOutputDir = cargoOutputDir.canonicalFile + @Internal + var rootBuildDir: File = File("") - val intoDir = File(buildDir, "rustJniLibs/${toolchain.folder}") - intoDir.mkdirs() + @Input + var cargoCommand: String = "cargo" - copy { spec -> - spec.from(cargoOutputDir) - spec.into(intoDir) + @Input + var rustcCommand: String = "rustc" - // Need to capture the value to dereference smoothly. - val targetIncludes = targetIncludes - if (targetIncludes != null) { - spec.include(targetIncludes.asIterable()) - } else { - // It's safe to unwrap, since we bailed at configuration time if this is unset. - val libname = libname!! - spec.include("lib${libname}.so") - spec.include("lib${libname}.dylib") - spec.include("${libname}.dll") - } - } - } - } + @Input + var rustupChannel: String = "" + + @Input + var pythonCommand: String = "python" + + @Input + var module: String = "" + + @Input + @Optional + var libname: String? = null + + @Input + @Optional + var verbose: Boolean? = null + + @Input + var profile: String = "debug" + + @Input + @Optional + var cargoTargetDir: String? = null + + @Input + @Optional + var targetIncludes: Array? = null + + @Input + var featureSpec: FeatureSpec = FeatureSpec() + + @Input + @Optional + var extraCargoBuildArguments: List? = null - inline fun buildProjectForTarget(project: Project, toolchain: Toolchain, ndk: Ndk, cargoExtension: CargoExtension) { - val apiLevel = cargoExtension.apiLevels[toolchain.platform]!! - val defaultTargetTriple = getDefaultTargetTriple(project, cargoExtension.rustcCommand) + @Input + var apiLevels: Map = mapOf() + + @Input + var generateBuildId: Boolean = false + + @Internal + var toolchainDirectory: File = File("") + + @Input + var autoConfigureClangSys: Boolean = false + + @Input + var targetProperties: Map = mapOf() - project.exec { spec -> + @Internal + var execClosure: ((ExecSpec, Toolchain) -> Unit)? = null + + @Suppress("unused") + @TaskAction + fun build() { + val toolchain = toolchain ?: throw GradleException("toolchain cannot be null") + val ndk = ndk ?: throw GradleException("ndk cannot be null") + + val apiLevel = apiLevels[toolchain.platform]!! + val defaultTargetTriple = getDefaultTargetTriple(execOperations, logger, rustcCommand) + + execOperations.exec { spec -> with(spec) { standardOutput = System.out - val module = File(cargoExtension.module!!) - if (module.isAbsolute) { - workingDir = module + val moduleFile = File(module) + if (moduleFile.isAbsolute) { + workingDir = moduleFile } else { - workingDir = File(project.project.projectDir, module.path) + workingDir = File(projectDir, moduleFile.path) } workingDir = workingDir.canonicalFile - val theCommandLine = mutableListOf(cargoExtension.cargoCommand) + val theCommandLine = mutableListOf(cargoCommand) - if (!cargoExtension.rustupChannel.isEmpty()) { - val hasPlusSign = cargoExtension.rustupChannel.startsWith("+") + if (!rustupChannel.isEmpty()) { + val hasPlusSign = rustupChannel.startsWith("+") val maybePlusSign = if (!hasPlusSign) "+" else "" - theCommandLine.add(maybePlusSign + cargoExtension.rustupChannel) + theCommandLine.add(maybePlusSign + rustupChannel) } theCommandLine.add("build") // Respect `verbose` if it is set; otherwise, log if asked to // with `--info` or `--debug` from the command line. - if (cargoExtension.verbose ?: project.logger.isEnabled(LogLevel.INFO)) { + if (verbose ?: logger.isEnabled(LogLevel.INFO)) { theCommandLine.add("--verbose") } - val features = cargoExtension.featureSpec.features + val features = featureSpec.features // We just pass this along to cargo as something space separated... AFAICT // you're allowed to have featureSpec with spaces in them, but I don't think // there's a way to specify them in the cargo command line -- rustc accepts @@ -139,11 +158,11 @@ open class CargoBuildTask : DefaultTask() { } } - if (cargoExtension.profile != "debug") { + if (profile != "debug") { // Cargo is rigid: it accepts "--release" for release (and // nothing for dev). This is a cheap way of allowing only // two values. - theCommandLine.add("--${cargoExtension.profile}") + theCommandLine.add("--${profile}") } if (toolchain.target != defaultTargetTriple) { // Only providing --target for the non-default targets means desktop builds @@ -158,13 +177,13 @@ open class CargoBuildTask : DefaultTask() { val prefix = "RUST_ANDROID_GRADLE_TARGET_${toolchain_target}_" // For ORG_GRADLE_PROJECT_RUST_ANDROID_GRADLE_TARGET_x_KEY=VALUE, set KEY=VALUE. - project.logger.info("Passing through project properties with prefix '${prefix}' (environment variables with prefix 'ORG_GRADLE_PROJECT_${prefix}'") - project.properties.forEach { (key, value) -> - if (key.startsWith(prefix)) { - val realKey = key.substring(prefix.length) - project.logger.debug("Passing through environment variable '${key}' as '${realKey}=${value}'") - environment(realKey, value) - } + logger.info("Passing through project properties with prefix '${prefix}' (environment variables with prefix 'ORG_GRADLE_PROJECT_${prefix}'") + targetProperties.forEach { (key, value) -> + if (key.startsWith(prefix)) { + val realKey = key.substring(prefix.length) + logger.debug("Passing through environment variable '${key}' as '${realKey}=${value}'") + environment(realKey, value) + } } // Cross-compiling to Android requires toolchain massaging. @@ -172,7 +191,7 @@ open class CargoBuildTask : DefaultTask() { val ndkPath = ndk.path val ndkVersionMajor = ndk.versionMajor - val toolchainDirectory = if (toolchain.type == ToolchainType.ANDROID_PREBUILT) { + val toolchainDir = if (toolchain.type == ToolchainType.ANDROID_PREBUILT) { environment("CARGO_NDK_MAJOR_VERSION", ndkVersionMajor) val hostTag = if (Os.isFamily(Os.FAMILY_WINDOWS)) { @@ -188,20 +207,20 @@ open class CargoBuildTask : DefaultTask() { } File("$ndkPath/toolchains/llvm/prebuilt", hostTag) } else { - cargoExtension.toolchainDirectory + toolchainDirectory } val linker_wrapper = if (System.getProperty("os.name").startsWith("Windows")) { - File(project.rootProject.buildDir, "linker-wrapper/linker-wrapper.bat") + File(rootBuildDir, "linker-wrapper/linker-wrapper.bat") } else { - File(project.rootProject.buildDir, "linker-wrapper/linker-wrapper.sh") + File(rootBuildDir, "linker-wrapper/linker-wrapper.sh") } environment("CARGO_TARGET_${toolchain_target}_LINKER", linker_wrapper.path) - val cc = File(toolchainDirectory, "${toolchain.cc(apiLevel)}").path; - val cxx = File(toolchainDirectory, "${toolchain.cxx(apiLevel)}").path; - val ar = File(toolchainDirectory, "${toolchain.ar(apiLevel, ndkVersionMajor)}").path; + val cc = File(toolchainDir, "${toolchain.cc(apiLevel)}").path; + val cxx = File(toolchainDir, "${toolchain.cxx(apiLevel)}").path; + val ar = File(toolchainDir, "${toolchain.ar(apiLevel, ndkVersionMajor)}").path; // For build.rs in `cc` consumers: like "CC_i686-linux-android". See // https://github.com/alexcrichton/cc-rs#external-configuration-via-environment-variables. @@ -212,51 +231,78 @@ open class CargoBuildTask : DefaultTask() { // Set CLANG_PATH in the environment, so that bindgen (or anything // else using clang-sys in a build.rs) works properly, and doesn't // use host headers and such. - val shouldConfigure = cargoExtension.getFlagProperty( - "rust.autoConfigureClangSys", - "RUST_ANDROID_GRADLE_AUTO_CONFIGURE_CLANG_SYS", - // By default, only do this for non-desktop platforms. If we're - // building for desktop, things should work out of the box. - toolchain.type != ToolchainType.DESKTOP - ) - if (shouldConfigure) { + if (autoConfigureClangSys) { environment("CLANG_PATH", cc) } // Configure our linker wrapper. - environment("RUST_ANDROID_GRADLE_PYTHON_COMMAND", cargoExtension.pythonCommand) + environment("RUST_ANDROID_GRADLE_PYTHON_COMMAND", pythonCommand) environment("RUST_ANDROID_GRADLE_LINKER_WRAPPER_PY", - File(project.rootProject.buildDir, "linker-wrapper/linker-wrapper.py").path) + File(rootBuildDir, "linker-wrapper/linker-wrapper.py").path) environment("RUST_ANDROID_GRADLE_CC", cc) - if (cargoExtension.generateBuildId) { - environment("RUST_ANDROID_GRADLE_CC_LINK_ARG", "-Wl,--build-id,-soname,lib${cargoExtension.libname!!}.so") + if (generateBuildId) { + environment("RUST_ANDROID_GRADLE_CC_LINK_ARG", "-Wl,--build-id,-soname,lib${libname!!}.so") } else { - environment("RUST_ANDROID_GRADLE_CC_LINK_ARG", "-Wl,-soname,lib${cargoExtension.libname!!}.so") + environment("RUST_ANDROID_GRADLE_CC_LINK_ARG", "-Wl,-soname,lib${libname!!}.so") } } - cargoExtension.extraCargoBuildArguments?.let { + extraCargoBuildArguments?.let { theCommandLine.addAll(it) } commandLine = theCommandLine } - if (cargoExtension.exec != null) { - (cargoExtension.exec!!)(spec, toolchain) + if (execClosure != null) { + (execClosure!!)(spec, toolchain) } }.assertNormalExitValue() + + // CARGO_TARGET_DIR can be used to force the use of a global, shared target directory + // across all rust projects on a machine. Use it if it's set, otherwise use the + // configured `targetDirectory` value, and fall back to `${module}/target`. + val target = cargoTargetDir ?: "${module}/target" + + var cargoOutputDir = File(if (toolchain.target == defaultTargetTriple) { + "${target}/${profile}" + } else { + "${target}/${toolchain.target}/${profile}" + }) + if (!cargoOutputDir.isAbsolute) { + cargoOutputDir = File(projectDir, cargoOutputDir.path) + } + cargoOutputDir = cargoOutputDir.canonicalFile + + val intoDir = File(buildDir, "rustJniLibs/${toolchain.folder}") + intoDir.mkdirs() + + fileSystemOperations.copy { spec -> + spec.from(cargoOutputDir) + spec.into(intoDir) + + // Need to capture the value to dereference smoothly. + val targetIncludes = targetIncludes + if (targetIncludes != null) { + spec.include(targetIncludes.asIterable()) + } else { + // It's safe to unwrap, since we bailed at configuration time if this is unset. + val libname = libname!! + spec.include("lib${libname}.so") + spec.include("lib${libname}.dylib") + spec.include("${libname}.dll") + } + } } } -// This can't be private/internal as it's called from `buildProjectForTarget`. -fun getDefaultTargetTriple(project: Project, rustc: String): String? { +fun getDefaultTargetTriple(execOperations: ExecOperations, logger: org.gradle.api.logging.Logger, rustc: String): String? { val stdout = ByteArrayOutputStream() - val result = project.exec { spec -> + val result = execOperations.exec { spec -> spec.standardOutput = stdout spec.commandLine = listOf(rustc, "--version", "--verbose") } if (result.exitValue != 0) { - project.logger.warn( + logger.warn( "Failed to get default target triple from rustc (exit code: ${result.exitValue})") return null } @@ -271,9 +317,9 @@ fun getDefaultTargetTriple(project: Project, rustc: String): String? { ?.let { it.substring(triplePrefix.length).trim() } if (triple == null) { - project.logger.warn("Failed to parse `rustc -Vv` output! (Please report a rust-android-gradle bug)") + logger.warn("Failed to parse `rustc -Vv` output! (Please report a rust-android-gradle bug)") } else { - project.logger.info("Default rust target triple: $triple") + logger.info("Default rust target triple: $triple") } return triple } diff --git a/plugin/src/main/kotlin/com/nishtahir/CargoExtension.kt b/plugin/src/main/kotlin/com/nishtahir/CargoExtension.kt index e0b0995b..858d6199 100644 --- a/plugin/src/main/kotlin/com/nishtahir/CargoExtension.kt +++ b/plugin/src/main/kotlin/com/nishtahir/CargoExtension.kt @@ -7,7 +7,7 @@ import org.gradle.process.ExecSpec import java.io.File import java.util.* -sealed class Features { +sealed class Features : java.io.Serializable { class All() : Features() data class DefaultAnd(val featureSet: Set) : Features() @@ -15,7 +15,7 @@ sealed class Features { data class NoDefaultBut(val featureSet: Set) : Features() } -data class FeatureSpec(var features: Features? = null) { +data class FeatureSpec(var features: Features? = null) : java.io.Serializable { fun all() { this.features = Features.All() } diff --git a/plugin/src/main/kotlin/com/nishtahir/GenerateToolchainsTask.kt b/plugin/src/main/kotlin/com/nishtahir/GenerateToolchainsTask.kt index a8d3e7d5..fef3f208 100644 --- a/plugin/src/main/kotlin/com/nishtahir/GenerateToolchainsTask.kt +++ b/plugin/src/main/kotlin/com/nishtahir/GenerateToolchainsTask.kt @@ -2,39 +2,43 @@ package com.nishtahir import java.io.File -import com.android.build.gradle.* import org.gradle.api.DefaultTask import org.gradle.api.GradleException -import org.gradle.api.Project +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import javax.inject.Inject -open class GenerateToolchainsTask : DefaultTask() { +abstract class GenerateToolchainsTask : DefaultTask() { - @TaskAction - @Suppress("unused") - fun generateToolchainTask() { - project.plugins.all { - when (it) { - is AppPlugin -> configureTask(project) - is LibraryPlugin -> configureTask(project) - } - } - } + @get:Inject + abstract val execOperations: ExecOperations + + @Input + var targets: List = listOf() - inline fun configureTask(project: Project) { - val cargoExtension = project.extensions[CargoExtension::class] - val app = project.extensions[T::class] - val ndkPath = app.ndkDirectory + @Input + var apiLevels: Map = mapOf() - // It's safe to unwrap, since we bailed at configuration time if this is unset. - val targets = cargoExtension.targets!! + @Input + var pythonCommand: String = "python" + @Internal + var toolchainDirectory: File = File("") + + @Internal + var ndkDirectory: File = File("") + + @TaskAction + @Suppress("unused") + fun generateToolchainTask() { toolchains .filter { it.type == ToolchainType.ANDROID_GENERATED } .filter { (arch) -> targets.contains(arch) } .forEach { (arch) -> // We ensure all architectures have an API level at configuration time - val apiLevel = cargoExtension.apiLevels[arch]!! + val apiLevel = apiLevels[arch]!! if (arch.endsWith("64") && apiLevel < 21) { throw GradleException("Can't target 64-bit ${arch} with API level < 21 (${apiLevel})") @@ -43,12 +47,12 @@ open class GenerateToolchainsTask : DefaultTask() { // Always regenerate the toolchain, even if it exists // already. It is fast to do so and fixes any issues // with partially reclaimed temporary files. - val dir = File(cargoExtension.toolchainDirectory, arch + "-" + apiLevel) - project.exec { spec -> + val dir = File(toolchainDirectory, arch + "-" + apiLevel) + execOperations.exec { spec -> spec.standardOutput = System.out spec.errorOutput = System.out - spec.commandLine(cargoExtension.pythonCommand) - spec.args("$ndkPath/build/tools/make_standalone_toolchain.py", + spec.commandLine(pythonCommand) + spec.args("$ndkDirectory/build/tools/make_standalone_toolchain.py", "--arch=$arch", "--api=$apiLevel", "--install-dir=${dir}", diff --git a/plugin/src/main/kotlin/com/nishtahir/RustAndroidPlugin.kt b/plugin/src/main/kotlin/com/nishtahir/RustAndroidPlugin.kt index 51662749..9a4dbe71 100644 --- a/plugin/src/main/kotlin/com/nishtahir/RustAndroidPlugin.kt +++ b/plugin/src/main/kotlin/com/nishtahir/RustAndroidPlugin.kt @@ -107,7 +107,7 @@ val toolchains = listOf( "android/x86_64") ) -data class Ndk(val path: File, val version: String) { +data class Ndk(val path: File, val version: String) : java.io.Serializable { val versionMajor: Int get() = version.split(".").first().toInt() } @@ -117,7 +117,7 @@ data class Toolchain(val platform: String, val target: String, val compilerTriple: String, val binutilsTriple: String, - val folder: String) { + val folder: String) : java.io.Serializable { fun cc(apiLevel: Int): File = if (System.getProperty("os.name").startsWith("Windows")) { if (type == ToolchainType.ANDROID_PREBUILT) { @@ -257,6 +257,11 @@ open class RustAndroidPlugin : Plugin { GenerateToolchainsTask::class.java).apply { group = RUST_TASK_GROUP description = "Generate standard toolchain for given architectures" + targets = cargoExtension.targets!! + apiLevels = cargoExtension.apiLevels + pythonCommand = cargoExtension.pythonCommand + this.toolchainDirectory = cargoExtension.toolchainDirectory + ndkDirectory = extensions[T::class].ndkDirectory } } else { null @@ -307,6 +312,37 @@ open class RustAndroidPlugin : Plugin { description = "Build library ($target)" toolchain = theToolchain this.ndk = ndk + projectDir = project.projectDir + this.buildDir = project.buildDir + rootBuildDir = project.rootProject.buildDir + cargoCommand = cargoExtension.cargoCommand + rustcCommand = cargoExtension.rustcCommand + rustupChannel = cargoExtension.rustupChannel + pythonCommand = cargoExtension.pythonCommand + module = cargoExtension.module!! + libname = cargoExtension.libname + verbose = cargoExtension.verbose + profile = cargoExtension.profile + cargoTargetDir = cargoExtension.getProperty("rust.cargoTargetDir", "CARGO_TARGET_DIR") + ?: cargoExtension.targetDirectory + targetIncludes = cargoExtension.targetIncludes + featureSpec = cargoExtension.featureSpec + extraCargoBuildArguments = cargoExtension.extraCargoBuildArguments + apiLevels = cargoExtension.apiLevels + generateBuildId = cargoExtension.generateBuildId + this.toolchainDirectory = cargoExtension.toolchainDirectory + autoConfigureClangSys = cargoExtension.getFlagProperty( + "rust.autoConfigureClangSys", + "RUST_ANDROID_GRADLE_AUTO_CONFIGURE_CLANG_SYS", + theToolchain.type != ToolchainType.DESKTOP + ) + targetProperties = project.properties + .filterKeys { it.startsWith("RUST_ANDROID_GRADLE_TARGET_") } + .mapValues { it.value?.toString() ?: "" } + cargoExtension.exec?.let { + logger.warn("rust-android-gradle: cargo.exec closure is not compatible with Gradle configuration cache") + execClosure = it + } } if (!usePrebuilt) {