Engineering

A task that captures Project is a bug, not a gap

Calling it a configuration cache incompatibility frames a real defect as a missing feature. The task captures a live object where it should capture a value.

MobileByteSensei 10 min read
A sealed core, and the borehole you would otherwise have to go back down
gradlebuildkotlin

A build reports that a task is incompatible with the configuration cache. The task is yours. The message names it precisely and explains what it did.

The word incompatible is doing a lot of quiet work in that sentence, and most of the bad decisions that follow come from taking it at face value.

The case this post is about

One build task, reported as incompatible with the configuration cache, and the distinction the word incompatible hides:

capture a value      → known at configuration time, serializable, stable
capture the project  → a handle to a mutable graph, only valid while it exists

The task captured the project object and used it at execution time. The report reads like a feature the build has not adopted yet; what it actually says is that the task reads mutable state at a moment when that state is not guaranteed to exist.

There is a flag that silences it. This post follows that one task, because taking the flag buys a slower build and keeps the defect — the cache was the only thing reporting a real correctness problem, and disabling it removes the report rather than the cause.

The symptom

What you see is a build that was fast yesterday refusing to reuse its cached configuration, with a message naming a task and a reason — typically that the task captured the project object and used it while executing.

The framing invites exactly one reading: my build has a feature it does not fully support yet. Incompatibility is a word about two things not fitting, which puts the problem in the space between your task and the cache rather than inside the task. And if the problem is in the gap, the natural remedy is to stop using the thing that does not fit.

An open borehole, which can only answer by going back down into a formation that has moved

The second thing is that the remedy is already in your hand. There is a flag, it is usually mentioned nearby, and turning the cache off makes the message disappear and the build succeed. Thirty seconds, no code changed, problem apparently solved — and now every build on the machine pays for it, forever, in exchange for a silence.

The third is what the message was actually telling you. A task that captures the project object is holding a handle to a live, mutable object graph and planning to interrogate it later. That works only because the whole build is being re-evaluated from scratch on every run. The cache was not failing to support your task; it was reporting that your task cannot be described without running the configuration phase again, which is a statement about the task.

The fourth is that this defect has other symptoms you will meet separately and not connect. The same property — reading mutable shared state at execution time — is what makes a task misbehave under parallel execution, and what makes its build-cache key wrong. Those failures arrive months apart, look unrelated, and have one cause that you were told about once, in a message you suppressed.

Why the obvious fix failed

Turn the configuration cache off. The flag is right there and it works. It also converts a specific, named, located defect into a general slowdown of every build, and it removes the only mechanism that was going to tell you about the next one. You have traded a diagnostic for latency, which is a bad exchange in both directions.

Mark the task as incompatible. Better engineering instinct — scope the opt-out to the one task, keep the cache everywhere else. And it is worse in one specific way: a declared incompatibility is permanent and silent, where a reported one is noisy and temporary. You have moved the defect from something the build complains about to something the build is configured to accept, which is the difference between a bug and a decision.

Wait for upstream to fix it. Entirely correct when the task belongs to a third-party plugin and you have reported it. It is not a stance you can take about your own tasks, and in practice the waiting tends to get applied to both, because by then the opt-out is already in place and nothing distinguishes the two cases.

Rewrite the task. The heavyweight response, and usually unnecessary. The defect is almost always one or two lines — a reference held where a value should have been — and treating it as a rewrite is how it gets deferred indefinitely.

The actual mechanism

The distinction the whole thing rests on is between capturing a value and capturing a live reference.

A value is a thing you took at a moment. It can be written down, stored, moved, and it remains exactly what it was. A live reference is a promise to go and ask later — and "later" is a different time, in a different process, possibly with the object in a different state or not present at all.

capture a value      → known at configuration time, serializable, stable
capture the project  → a handle to a mutable graph, only valid while it exists

The configuration cache's whole premise is that the result of the configuration phase can be written down and replayed. A task holding a live reference cannot be written down, because the thing it holds is not data — it is access. That is not a gap in the cache's coverage. It is the task declining to say what it needs.

A core sample sealed at the face, which remains exactly what it was

The fix follows from the framing and is usually small: resolve what you need during configuration, hold it in a property, and use the property at execution. The task stops asking questions later and starts stating its inputs, which is what a task was always supposed to do.

The sub-case worth knowing is what to do when the value genuinely is not available yet — when it depends on something that has not been computed at configuration time. There is a sanctioned way to defer, and the important thing about it is what it defers: a provider defers a value, not an object graph. You are still declaring what you need; you are declining to compute it immediately. That distinction is the entire difference between a lazily-resolved input and a reference held open.

A deferred value still declares what it is; it just arrives later

There is one more property of this diagnostic worth appreciating, because it is unusual. It names the task. Most correctness bugs of this family — reading shared mutable state at the wrong moment — are discovered as intermittent failures with no obvious origin. This one arrives with a location attached, which makes it among the cheapest bugs of its class you will ever be handed.

The fix

find    the capture — usually project, sometimes another live service
resolve what it was used for, at configuration time
hold    the result in a task property
use     the property at execution
keep    the cache enabled, so the next one is reported too

Before and after on one real task. The capture is project.version read inside the action — which is configuration-time state reached at execution time:

// BEFORE — serialises `project` into the task graph; config cache refuses it
abstract class StampBuildTask : DefaultTask() {
    @TaskAction
    fun stamp() {
        val out = project.layout.buildDirectory.file("stamp.txt").get().asFile
        out.writeText("version=${project.version}\nsha=${gitSha()}")
    }
    private fun gitSha() = project.providers
        .exec { commandLine("git", "rev-parse", "HEAD") }
        .standardOutput.asText.get().trim()
}

// AFTER — everything resolved at CONFIGURATION time, held in properties
abstract class StampBuildTask : DefaultTask() {
    @get:Input abstract val version: Property<String>
    @get:Input abstract val gitSha: Property<String>
    @get:OutputFile abstract val stampFile: RegularFileProperty

    @TaskAction
    fun stamp() = stampFile.get().asFile.writeText(
        "version=${version.get()}\nsha=${gitSha.get()}",     // no `project` at execution
    )
}

tasks.register<StampBuildTask>("stampBuild") {
    version.set(project.version.toString())
    gitSha.set(providers.exec { commandLine("git", "rev-parse", "HEAD") }
        .standardOutput.asText.map { it.trim() })
    stampFile.set(layout.buildDirectory.file("stamp.txt"))
}

With the cache left enabled, which is the part people disable:

$ ./gradlew stampBuild --configuration-cache
  invocation of 'Task.project' at execution time is unsupported  (:stampBuild)
BUILD FAILED

$ ./gradlew stampBuild --configuration-cache     # after
Configuration cache entry stored.
BUILD SUCCESSFUL in 1s

$ ./gradlew stampBuild --configuration-cache     # second run
Reusing configuration cache.
BUILD SUCCESSFUL in 340ms

The last line is the one that matters beyond this task. Every suppression is a decision to not be told next time, and the next time will be a different task written by somebody who did not read this message. Keeping the cache on is less about this build's speed than about preserving the mechanism.

There is a question of how you find these before the cache finds them for you, and the honest answer is that mostly you do not — the diagnostic is the discovery mechanism, which is another reason to keep it on. What you can do is recognise the shape while writing: any time a task body reaches for something outside its own declared inputs, that is the moment. The reach is the tell, not the type of the thing being reached for.

Where the task is template-derived or lives in a shared plugin, the fix belongs at its source rather than in a local patch — same reasoning as anywhere else, with the added point that a locally patched build task is exactly the kind of thing a template sync removes without comment.

How we proved it

The claim I can demonstrate is the diagnostic one. The cache reports the incompatibility by task name and by reason. Fixing the capture restores cache reuse on the next run, and the restoration is observable in the build's own output rather than inferred from timing. That round trip — reported, fixed, reported as reused — is the whole mechanical story and it is checkable in two builds.

The speed consequence is the same measurement as the daemon's. A build that cannot reuse its configuration re-evaluates every script on every invocation, which is the difference between a build you wait through and one you do not notice.

The fix size is worth reporting too, because it is the thing that changes people's willingness to do it rather than suppress it. In the cases I have dealt with, the change was a handful of lines: read the value during configuration, put it in a property, use the property. Not a redesign, not a new abstraction — a relocation of one expression from one phase to another. The gap between that and "turn the cache off" is about two minutes, and the reason the two-minute option loses is almost entirely framing rather than effort.

What I have not evidenced is the correctness half, and it is the more interesting claim. I have argued that the same capture causes problems under parallel execution and incorrect build-cache keys. I have not produced a case where one of those bit me and traced it back to a task I had suppressed — so that part of the argument is reasoning about the mechanism rather than a reported incident, and should be read that way.

What I have not solved: third-party tasks. When the capture is inside a plugin, the choices are to report it upstream and wait, or to take the scoped opt-out while waiting. I use the opt-out, which means my own build contains exactly the declared-and-forgotten suppression this post argues against — with the thin justification that it is annotated with a reason and a link. Thin, because nothing re-checks whether upstream has fixed it, and the suppression will outlive the bug.

One capture, and the two different things it can be

And a limitation of the framing itself: not every reported incompatibility is a defect in your code. Some genuinely are gaps — a build feature interacting with a plugin in a way neither anticipated — and treating every report as "your task is wrong" would be its own error. The test is whether the task can state its inputs as data. If it can and does not, it is a bug. If it genuinely cannot, that is a real conversation with the tool.

What this generalizes to

The name a diagnostic gives itself decides whether people fix it or suppress it, and that naming is rarely chosen with that effect in mind.

Incompatible places the fault between two things. Deprecated suggests a schedule rather than a problem. Warning implies optional. Each of those framings produces a predictable response, and in every case the response is weaker than the one the underlying condition deserves — because the word was chosen to describe the detection, not the defect.

So when a tool tells you something is incompatible, unsupported or not yet handled, read past the framing to the behaviour being reported. The useful question is not how do I make this message stop but what is it claiming my code does. Often the answer is a real property you would have wanted to know about regardless, arriving with a file name and a line number, which is a better deal than most bugs offer.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS