clean's doLast closes SpotlessCache classloaders that concurrently-running spotless tasks still hold (LINE_UNDEFINED NoClassDefFoundError/InvocationTargetException)
- Dominant language
- Java
- Stars
- 5.6k
- Forks
- 559
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 34
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
```
:LINE_UNDEFINED (java.lang.NoClassDefFoundError)
:LINE_UNDEFINED (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 cache** — [`SpotlessPlugin.java:63-64`](https://github.com/diffplug/spotless/blob/main/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessPlugin.java#L63-L64):
```java
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 loaders** — [`SpotlessCache.java:85-98`](https://github.com/diffplug/spotless/blob/main/lib/src/main/java/com/diffplug/spotless/SpotlessCache.java#L85-L98):
```java
private static void clear() {
List 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](https://github.com/diffplug/spotless/blob/main/lib/src/main/java/com/diffplug/spotless/SpotlessCache.java#L69)) 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](https://github.com/diffplug/spotless/blob/main/lib/src/main/java/com/diffplug/spotless/SpotlessCache.java#L91), 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.
Contributor guide
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.
Assessment
- Tech stack
- java
- Domain
- build-system
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100