You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
clean's doLast closes SpotlessCache classloaders that concurrently-running spotless tasks still hold (LINE_UNDEFINED NoClassDefFoundError/InvocationTargetException) #3067
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
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.
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.
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.
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:
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:
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.
Reference-count the loaders.close() when the last holder releases. Correct, but needs every FormatterStep consumer to release.
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.
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)
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.
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.
Summary
SpotlessPluginadds, to every project'scleantask, adoLastthat callsSpotlessCache.clearOnce(...), andSpotlessCache.clear()closes every cachedURLClassLoader. Nothing prevents that from happening while another project'sspotless*Check/spotless*Applytask is concurrently using one of those classloaders. A closedURLClassLoaderkeeps 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 asThis is, I believe, the underlying cause of #2862 — which is why that issue collects the same symptom across
eclipse jdt formatter,removeUnusedImports,ktlintandprettier: the fault is in the shared classloader cache, not in any one formatter.Mechanism
1. Every project's
cleanclears the cache —SpotlessPlugin.java:63-64:applyruns per project, so in a multi-project build everycleancarries thisdoLast. TheclearOncekey is the root project, so only the firstcleanto finish actually clears — but one is enough.2. Clearing closes the loaders —
SpotlessCache.java:85-98:3. Holders are unaffected by the lock.
classloader(Serializable, JarState)(:69) issynchronized, 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 thesynchronizedblock 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=trueand Spotless applied to several projects,:a:cleanexecutes 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:
com/pinterest/ktlint/rule/engine/api/EditorConfigDefaultson one run andkotlin/collections/ArraysKt___ArraysJvmKton 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.build.gradle.ktsin "a very different place" is expected.JarState, hence theSerializedKey, hence which loaders exist — and it re-rolls task timing.InvocationTargetExceptionvsNoClassDefFoundErroris just whether the failing load happened inside a reflective call or not.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, andcleanin the same invocation ascheck/build.Failed on the first attempt with 5 ×
LINE_UNDEFINED ktlint(java.lang.NoClassDefFoundError)in:api:auth:spotlessKotlinCheck. An--infolog shows:core:graphql:cleanand:service:directory:cleanexecuting interleaved with thespotless*tasks, with the failures landing immediately after a batch ofcleantasks.Workaround (for anyone arriving from a search)
Run
cleanas its own invocation, so nocleanis in the task graph that runs thespotless*tasks:3/3 consecutive green here, 73
spotless*tasks genuinely executed each run, versus a first-try failure for the single combined invocation.--no-parallelshould also close the window, at the cost of the whole build's parallelism. Notably--stopis 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:
close()is there — but it makes the failure impossible.close()when the last holder releases. Correct, but needs everyFormatterStepconsumer to release.cleanhook not fire mid-build. Registering the clear as a build-finished action rather than adoLaston eachcleanwould keep the intent (acleaninvalidates the cache) without closing loaders while tasks are running.spotless*taskmustRunAftereveryclean, so the clear cannot land mid-flight.Option 3 seems closest to the original intent at the lowest cost.
Environment
spotless-lib4.10.2) — the relevant code is byte-identical tomainas of todayspotless-lib8.10.2 ships one adapter,KtLintCompat1Dot0Dot0Adapter, for all of ktlint 1.x)org.gradle.parallel=true,org.gradle.caching=true,org.gradle.configuration-cache=trueThe 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,clearOnceorFeatureClassLoadercurrently returns nothing.