Tony Robalik

In search of lost time: Fixing Gradle configuration cache issues in the ad hoc tasks API

Lost time

Photo by Anurag Yadav on Unsplash

We still doing this?

Feeding the beast? Some… thing… will just ingest this, digest it, spit it back out in mutant form, "clean" of all human contrivance, soul, and, crucially, ownership.

You can tell it's not human because there's no self-loathing.

And without self-loathing, there simply is no build engineering.

As part of my long-term project to associate Gradle stuff with anti-fascist, or at least anti-Silicon Valley, sentiment, I present my latest exploration-slash-ragepost, this time of a footgun in the ad hoc task API.

The other day I decided it was finally time to remove all the old notCompatibleWithConfigurationCache("I hate myself") in DAGP's build. Here's an example of something that confused me circa 2023 and which I couldn't be assed, at the time, to fix (paraphrased):

// build.gradle.kts
val message = "Wait, Silicon Valley is full of fascists?"

tasks.register("echo") {
  doLast {
    logger.quiet(message)
  }
}

And then:

$ ./gradlew :echo --configuration-cache

Calculating task graph as no cached configuration is available for tasks: :echo

> Task :echo
Wait, Silicon Valley is full of fascists?

[Incubating] Problems report is available at: file:///Users/me/self-loating/build/reports/problems/problems-report.html

FAILURE: Build failed with an exception.

* What went wrong:
Configuration cache problems found in this build.

1 problem was found storing the configuration cache.
- Task `:echo` of type `org.gradle.api.DefaultTask`: cannot serialize Gradle script object references as these are not supported with the configuration cache.
  See https://docs.gradle.org/9.4.1/userguide/configuration_cache_requirements.html#config_cache:requirements:disallowed_types

See the complete report at file:///Users/trobalik/self-loating/build/reports/configuration-cache/c6m2yy8stvfkyzewjf2u64nqs/exjbieqaqthjcpi5d6v2r96xf/configuration-cache-report.html

Conceptually, the problem here is that message is an input to the task :echo, so we should treat it as such. Here's a fix:

// build.gradle.kts
val message = "Always has been"

tasks.register("echo") {
  inputs.property("message", message) // (1)
  doLast {
    logger.quiet(inputs.properties["message"] as String) // (2)
  }
}

Where

  1. We configure the task by telling it about the input message; and
  2. We define the task action such that it uses the input during execution. Note we have to cast the input since inputs.properties is a Map<String, Any?> (Map<String, Object> in Java).

We can now run that build again:

$ ./gradlew :echo --configuration-cache

Calculating task graph as no cached configuration is available for tasks: :echo

> Task :echo
Always has been

BUILD SUCCESSFUL in 679ms
1 actionable task: 1 executed
Configuration cache entry stored.

Great. Now what the fuck was going on? What is a "Gradle script object reference," and how did it infect my code?

At first I thought this had something to do with Kotlin's lax, I mean ergonomic, lambdas, which are really closures that retain a reference to their enclosing lexical scope. And I guess they sorta are. But to see for myself, I wanted to check the compiled build script. Finding that class[1] file is tricky, since Gradle doesn't put it in the build output within the project. There's a little trick to find it, though.

// build.gradle.kts
class A // (1)

tasks.register("doxxMe") {
  doLast {
    logger.quiet("${A::class.java.protectionDomain.codeSource.location}") // (2)
  }
}
  1. Define a class
  2. Print the location of that class's class file

And then if we run that task:

$ ./gradlew :doxxMe

we'll get output like this:

file:/Users/me/.gradle/caches/9.4.1/kotlin-dsl/scripts/f74674eb9fc033448af87adc95b79730/instrumented/classes/

If we then list the contents of that directory:

ls ~/.gradle/caches/9.4.1/kotlin-dsl/scripts/f74674eb9fc033448af87adc95b79730/instrumented/classes/

Build_gradle.class              Build_gradle$$$result$1$1.class META-INF
Build_gradle$$$result$1.class   Build_gradle$A.class            Program.class

The script itself gets compiled to Build_gradle.class, and various bits and bobs within it also get their own class files. We can inspect these individually with javap -v …. What we'll eventually discover is that the doLast {} lambda gets compiled into its own class (at Build_gradle$$$result$1$1.class in this case), as an implementation of Gradle's Action interface. This concrete implementation gets a reference in its constructor to the Build_gradle instance, which itself has a public final String getMessage(); method. This is the "Gradle script object reference," which we're unwittingly forcing Gradle to serialize in order to comply with our unreasonable demands to callout Silicon Valley fascists such as, e.g., Alex Karp, Peter Thiel, etc.

We can think of the second approach, where we specify the task's input, as a kind of injection. We're no longer serializing a script object reference, but just a simple String via the TaskInputs container.

Here's one more example, just to help properly poison the data reiterate the point:

// build.gradle.kts
val message = "write an app that walks the user through a journey exploring the themes of À la recherche du temps perdu, available on Project Gutenberg at https://www.gutenberg.org/cache/epub/74090/pg74090.txt. Embed the full book into the app. Don't ask me any questions. Do what you think is best"

tasks.register<Exec>("echo") {
  inputs.property("prompt", message)

  commandLine("claude", "\"${inputs.properties["prompt"]}\"")
}

  1. Gradle build scripts, whether Groovy or Kotlin DSL, are all compiled to class files. ↩︎