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

未关闭
#3,067 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 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 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.

主要语言
Java
星标
5.7k
派生
560
平均合并
1 天 13 小时
30 天内合并 PR
43

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

diffplug/spotless 的其他 Issue

查看 diffplug/spotless 的全部 Issue

相似的 Issue

更多 Java Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。