clean's doLast closes SpotlessCache classloaders that concurrently-running spotless tasks still hold (LINE_UNDEFINED NoClassDefFoundError/InvocationTargetException)
还没有人认领这个 Issue。
评估
- 难度
- 5/5
- 预计耗时
- 一周以上
- 新手友好度
- 35/100
- Issue 类型
- 缺陷
- 描述清晰度
- 需要澄清
- 活跃度
- 活跃
- 技术栈
- java
- 领域
- build-system
调研方向
阅读 plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessPlugin.java:63-64 和 lib/src/main/java/com/diffplug/spotless/SpotlessCache.java:69,85-98。在并行执行时使用 ./gradlew clean build --no-build-cache 重现问题,然后追踪缓存的 loader 是如何被持有和清除的。完成标准是:并发运行的 clean 和 spotless 任务均能完成,不出现报告的 NoClassDefFoundError 或 InvocationTargetException,同时保留缓存失效机制。
由索引模型根据 Issue 内容生成。
描述
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 cache — SpotlessPlugin.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 loaders — SpotlessCache.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/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.- 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.ktsin "a very different place" is expected. - Switching versions appears to "reset whatever state", because a version change alters the
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.- 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:
- 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 everyFormatterStepconsumer to release. - Make the
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. - At minimum, order it. Make every
spotless*taskmustRunAftereveryclean, 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-lib4.10.2) — the relevant code is byte-identical tomainas of today - ktlint 1.8.0 (the step is irrelevant;
spotless-lib8.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.
- 主要语言
- Java
- 星标
- 5.7k
- 派生
- 560
- 平均合并
- 1 天 13 小时
- 30 天内合并 PR
- 43
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
diffplug/spotless 的其他 Issue
-
难度 5/5 一周以上 新手友好度 15/100
-
难度 3/5 1-2 天 新手友好度 66/100
-
难度 3/5 1-2 天 新手友好度 68/100
-
难度 4/5 3-5 天 新手友好度 52/100
-
难度 3/5 1-2 天 新手友好度 68/100
查看 diffplug/spotless 的全部 Issue
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 82/100
infinispan/infinispan#18150 ·
-
难度 2/5 1-3 小时 新手友好度 84/100
-
untriaged
难度 2/5 1-3 小时 新手友好度 82/100
opensearch-project/k-NN#3597 ·
-
bug
难度 2/5 1-3 小时 新手友好度 88/100
-
bug
难度 2/5 1-3 小时 新手友好度 82/100