clean's doLast closes SpotlessCache classloaders that concurrently-running spotless tasks still hold (LINE_UNDEFINED NoClassDefFoundError/InvocationTargetException)

Open
#3,067 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
35/100
Issue type
Bug
Clarity
Needs clarification
Activity status
Active
Tech stack
java
Domain
build-system

Research direction

Read plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessPlugin.java:63-64 and lib/src/main/java/com/diffplug/spotless/SpotlessCache.java:69,85-98. Reproduce with ./gradlew clean build --no-build-cache under parallel execution, then trace how cached loaders are held and cleared. Done means concurrent clean and spotless tasks complete without the reported NoClassDefFoundError or InvocationTargetException while preserving cache invalidation.

Written by the indexing model from the issue text.

Description

Summary

SpotlessPlugin adds, to every project's clean task, a doLast that calls SpotlessCache.clearOnce(...), and SpotlessCache.clear() closes every cached URLClassLoader. Nothing prevents that from happening while another project's spotless*Check/spotless*Apply task is concurrently using one of those classloaders. A closed URLClassLoader keeps serving classes it has already defined but fails every new class load, so the formatter's engine dies partway through initialisation and Spotless reports it as

<some file>:LINE_UNDEFINED <stepName>(java.lang.NoClassDefFoundError)
<some file>:LINE_UNDEFINED <stepName>(java.lang.reflect.InvocationTargetException)

This is, I believe, the underlying cause of #2862 — which is why that issue collects the same symptom across eclipse jdt formatter, removeUnusedImports, ktlint and prettier: the fault is in the shared classloader cache, not in any one formatter.

Mechanism

1. Every project's clean clears the cacheSpotlessPlugin.java:63-64:

int cacheKey = System.identityHashCode(project.getRootProject());
project.getTasks().named(BasePlugin.CLEAN_TASK_NAME).configure(clean -> clean.doLast(unused -> SpotlessCache.clearOnce(cacheKey)));

apply runs per project, so in a multi-project build every clean carries this doLast. The clearOnce key is the root project, so only the first clean to finish actually clears — but one is enough.

2. Clearing closes the loadersSpotlessCache.java:85-98:

private static void clear() {
    List<URLClassLoader> toDelete;
    synchronized (INSTANCE) {
        toDelete = new ArrayList<>(INSTANCE.cache.values());
        INSTANCE.cache.clear();
    }
    for (URLClassLoader classLoader : toDelete) {
        try {
            classLoader.close();
        ...

3. Holders are unaffected by the lock. classloader(Serializable, JarState) (:69) is synchronized, but it returns the loader — a task holds and keeps using the reference long after releasing the monitor. So this is not merely "closes outside the lock" (though it does, at :91, after the synchronized block ends at :90): even closing inside the monitor would not help. Removing a loader from the cache is safe; closing it is not, because the cache does not know who still holds it.

With org.gradle.parallel=true and Spotless applied to several projects, :a:clean executes concurrently with :b:spotlessKotlinCheck, and the second one dies.

Why the symptom looks the way it does

Several things in #2862 that read as "confusing" fall out of this directly:

  • The named class varies between runs, because it depends on how far the engine got before the loader was closed. In my build I saw com/pinterest/ktlint/rule/engine/api/EditorConfigDefaults on one run and kotlin/collections/ArraysKt___ArraysJvmKt on another, same commit, same config. This is the clearest tell that it is a closed loader rather than a genuinely absent dependency — and it is why "your ktlint is outdated" / "add the missing dependency" advice never helps.
  • LINE_UNDEFINED, because the failure is in constructing the formatter, not in formatting a line, so there is no line to attribute it to. One is recorded per file the task had queued.
  • The blamed file is arbitrary — whatever the losing task happened to be working on. A commenter on #2862 noting ktlint's variant landing on build.gradle.kts in "a very different place" is expected.
  • Switching versions appears to "reset whatever state", because a version change alters the JarState, hence the SerializedKey, hence which loaders exist — and it re-rolls task timing.
  • InvocationTargetException vs NoClassDefFoundError is just whether the failing load happened inside a reflective call or not.
  • It reproduces on a freshly started daemon, since the race is intra-build, not cross-build state.

Reproduction

I have not reduced this to a standalone sample project; the evidence below is from a private 15-project Kotlin build plus reading the bytecode of the resolved jars and the source above.

Conditions: multi-project, Spotless applied to more than one project, org.gradle.parallel=true, and clean in the same invocation as check/build.

./gradlew clean build --no-build-cache

Failed on the first attempt with 5 × LINE_UNDEFINED ktlint(java.lang.NoClassDefFoundError) in :api:auth:spotlessKotlinCheck. An --info log shows :core:graphql:clean and :service:directory:clean executing interleaved with the spotless* tasks, with the failures landing immediately after a batch of clean tasks.

Workaround (for anyone arriving from a search)

Run clean as its own invocation, so no clean is in the task graph that runs the spotless* tasks:

./gradlew clean && ./gradlew build --no-build-cache

3/3 consecutive green here, 73 spotless* tasks genuinely executed each run, versus a first-try failure for the single combined invocation. --no-parallel should also close the window, at the cost of the whole build's parallelism. Notably --stop is not a fix — it only re-rolls the interleaving, which is presumably why it looks like it helps sometimes.

Suggested directions

I have not sent a PR because the right trade-off is yours to pick, but the options as I see them:

  1. Don't close, just evict. Drop the loaders from the cache and let GC collect them. Leaks the open jar file handles until collection, which is presumably why close() is there — but it makes the failure impossible.
  2. Reference-count the loaders. close() when the last holder releases. Correct, but needs every FormatterStep consumer to release.
  3. Make the clean hook not fire mid-build. Registering the clear as a build-finished action rather than a doLast on each clean would keep the intent (a clean invalidates the cache) without closing loaders while tasks are running.
  4. At minimum, order it. Make every spotless* task mustRunAfter every clean, so the clear cannot land mid-flight.

Option 3 seems closest to the original intent at the lowest cost.

Environment

  • Spotless Gradle plugin 8.10.2 (spotless-lib 4.10.2) — the relevant code is byte-identical to main as of today
  • ktlint 1.8.0 (the step is irrelevant; spotless-lib 8.10.2 ships one adapter, KtLintCompat1Dot0Dot0Adapter, for all of ktlint 1.x)
  • Gradle 9.6.0, daemon JVM Amazon Corretto 17.0.13, Kotlin 2.3.21, macOS aarch64
  • org.gradle.parallel=true, org.gradle.caching=true, org.gradle.configuration-cache=true

The Corretto 17 daemon is worth stating explicitly: some of the guesses circulating about this symptom blame Java 21+/25 classloader behaviour. It reproduces on 17.

Relation to #2862

I think #2862 is this bug reported symptomatically, and its reporters' various theories (Spotless version, Gradle 9.4.0, an outdated ktlint) are all downstream of timing changes rather than causes. Happy to have this closed as a duplicate if you agree — I filed separately only so the mechanism is searchable, since a search for SpotlessCache, clearOnce or FeatureClassLoader currently returns nothing.

Dominant language
Java
Stars
5.7k
Forks
560
Avg merge
1d 13h
Merged PRs (30d)
43

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from diffplug/spotless

All issues in diffplug/spotless

Similar issues

More Java issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.