From de80d370539047dbf8a2eafb59870b963aaeaaa8 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:45:57 +0700 Subject: [PATCH] fix: prevent ConfigurationCacheHackList fingerprint races on P2 Gradle fingerprints stepsInternalEquality by serializing ConfigurationCacheHackList, which eagerly provisions Eclipse/P2 jars. Parallel multi-project builds raced on Solstice's disk cache and failed with "cannot be serialized" / "Failed to provision P2 dependencies" (#3004). - Serialize P2 queries process-wide in P2Provisioner.createDefault() - Share one DedupingP2Provisioner across subprojects in SpotlessTaskService - ConfigurationCacheHackList.toString() no longer calls hashCode() (avoids re-provisioning while Gradle formats the error message) Fixes #3004 --- CHANGES.md | 2 + .../spotless/extra/P2Provisioner.java | 52 +++++++----- .../spotless/ConfigurationCacheHackList.java | 16 +++- plugin-gradle/CHANGES.md | 2 + .../gradle/spotless/SpotlessTaskService.java | 26 +++++- plugin-maven/CHANGES.md | 2 + .../ConfigurationCacheHackListTest.java | 81 +++++++++++++++++++ 7 files changed, 158 insertions(+), 23 deletions(-) create mode 100644 testlib/src/test/java/com/diffplug/spotless/ConfigurationCacheHackListTest.java diff --git a/CHANGES.md b/CHANGES.md index 5fac10fd37..ec4774345e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ This document is intended for Spotless developers. We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`). ## [Unreleased] +### Fixed +- Concurrent P2 provisioning (parallel multi-project Gradle fingerprinting of `eclipse()` / `greclipse()` steps) no longer races Solstice's on-disk cache; also `ConfigurationCacheHackList.toString()` no longer evaluates step state (which could re-trigger provisioning while Gradle reports "cannot be serialized"). ([#3004](https://github.com/diffplug/spotless/issues/3004)) ## [4.9.0] - 2026-07-27 ### Added diff --git a/lib-extra/src/main/java/com/diffplug/spotless/extra/P2Provisioner.java b/lib-extra/src/main/java/com/diffplug/spotless/extra/P2Provisioner.java index 3ce9486476..a68ce603e8 100644 --- a/lib-extra/src/main/java/com/diffplug/spotless/extra/P2Provisioner.java +++ b/lib-extra/src/main/java/com/diffplug/spotless/extra/P2Provisioner.java @@ -50,28 +50,42 @@ List provisionP2Dependencies( Provisioner mavenProvisioner, @Nullable File cacheDirectory) throws IOException; - /** Creates a non-caching P2Provisioner for simple use cases. */ + /** + * Creates a non-caching P2Provisioner for simple use cases. + *

+ * All queries are serialized on {@code P2Provisioner.class}. Gradle may fingerprint + * many Spotless tasks in parallel; each fingerprint serializes the equality + * {@code ConfigurationCacheHackList}, which eagerly resolves Eclipse/P2 jars. + * Concurrent Solstice queries race on the on-disk cache and fail with + * {@code Failed to provision P2 dependencies}, reported by Gradle as + * "ConfigurationCacheHackList cannot be serialized" + * (#3004, + * #2331). + */ static P2Provisioner createDefault() { return (modelWrapper, mavenProvisioner, cacheDirectory) -> { - try { - if (cacheDirectory != null) { - CacheLocations.override_p2data = cacheDirectory; - } - P2Model model = modelWrapper.unwrap(); - P2QueryResult query = model.query(P2ClientCache.PREFER_OFFLINE, P2QueryCache.ALLOW); - var classpath = new ArrayList(); - var mavenDeps = new ArrayList(); - mavenDeps.add("dev.equo.ide:solstice:1.8.1"); - mavenDeps.add("com.diffplug.durian:durian-swt.os:4.3.1"); - mavenDeps.addAll(query.getJarsOnMavenCentral()); - classpath.addAll(mavenProvisioner.provisionWithTransitives(false, mavenDeps)); - classpath.addAll(query.getJarsNotOnMavenCentral()); - for (var nested : NestedJars.inFiles(query.getJarsNotOnMavenCentral()).extractAllNestedJars()) { - classpath.add(nested.getValue()); + // Serialize all P2 queries in this JVM — Solstice's cache is not concurrent-safe. + synchronized (P2Provisioner.class) { + try { + if (cacheDirectory != null) { + CacheLocations.override_p2data = cacheDirectory; + } + P2Model model = modelWrapper.unwrap(); + P2QueryResult query = model.query(P2ClientCache.PREFER_OFFLINE, P2QueryCache.ALLOW); + var classpath = new ArrayList(); + var mavenDeps = new ArrayList(); + mavenDeps.add("dev.equo.ide:solstice:1.8.1"); + mavenDeps.add("com.diffplug.durian:durian-swt.os:4.3.1"); + mavenDeps.addAll(query.getJarsOnMavenCentral()); + classpath.addAll(mavenProvisioner.provisionWithTransitives(false, mavenDeps)); + classpath.addAll(query.getJarsNotOnMavenCentral()); + for (var nested : NestedJars.inFiles(query.getJarsNotOnMavenCentral()).extractAllNestedJars()) { + classpath.add(nested.getValue()); + } + return classpath; + } catch (Exception e) { + throw new IOException("Failed to provision P2 dependencies", e); } - return classpath; - } catch (Exception e) { - throw new IOException("Failed to provision P2 dependencies", e); } }; } diff --git a/lib/src/main/java/com/diffplug/spotless/ConfigurationCacheHackList.java b/lib/src/main/java/com/diffplug/spotless/ConfigurationCacheHackList.java index 7ae51024f2..adceaf1656 100644 --- a/lib/src/main/java/com/diffplug/spotless/ConfigurationCacheHackList.java +++ b/lib/src/main/java/com/diffplug/spotless/ConfigurationCacheHackList.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2025 DiffPlug + * Copyright 2024-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -150,4 +150,18 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(optimizeForEquality, backingList); } + + /** + * Must not call {@link #hashCode()} — that fingerprints every step and may provision + * P2/Maven deps. Gradle includes this value in "cannot be serialized" messages; a + * side-effecting {@code toString} re-triggers provisioning and masks the real cause + * (see #3004). + */ + @Override + public String toString() { + return getClass().getName() + + "@" + Integer.toHexString(System.identityHashCode(this)) + + "[optimizeForEquality=" + optimizeForEquality + + ", size=" + backingList.size() + "]"; + } } diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 296d18c172..732acdccbe 100644 --- a/plugin-gradle/CHANGES.md +++ b/plugin-gradle/CHANGES.md @@ -3,6 +3,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `3.27.0`). ## [Unreleased] +### Fixed +- Parallel multi-project builds no longer intermittently fail with "Cannot fingerprint input property 'stepsInternalEquality': ConfigurationCacheHackList cannot be serialized" / "Failed to provision P2 dependencies" when using `eclipse()` (or other P2-backed steps). Subprojects now share one deduping P2 provisioner and P2 queries are serialized process-wide. ([#3004](https://github.com/diffplug/spotless/issues/3004)) ## [8.9.0] - 2026-07-27 ### Added diff --git a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java index 90f282d873..c6fd993d20 100644 --- a/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java +++ b/plugin-gradle/src/main/java/com/diffplug/gradle/spotless/SpotlessTaskService.java @@ -59,10 +59,11 @@ public abstract class SpotlessTaskService implements BuildService apply = Collections.synchronizedMap(new HashMap<>()); private final Map source = Collections.synchronizedMap(new HashMap<>()); private final Map provisioner = Collections.synchronizedMap(new HashMap<>()); - private final Map p2Provisioner = Collections.synchronizedMap(new HashMap<>()); @Nullable GradleProvisioner.DedupingProvisioner predeclaredProvisioner; @Nullable GradleProvisioner.DedupingP2Provisioner predeclaredP2Provisioner; + /** Shared across subprojects so parallel fingerprinting reuses one P2 cache + lock. */ + @Nullable private volatile GradleProvisioner.DedupingP2Provisioner sharedP2Provisioner; @Nullable RegisterDependenciesTask registerDependenciesTask; Provisioner provisionerFor(SpotlessExtension spotless) { @@ -84,12 +85,31 @@ P2Provisioner p2ProvisionerFor(SpotlessExtension spotless) { if (predeclaredP2Provisioner != null) { return predeclaredP2Provisioner.cachedOnly; } else { - return p2Provisioner.computeIfAbsent(spotless.project.getPath(), - unused -> new GradleProvisioner.DedupingP2Provisioner(P2Provisioner.createDefault(), GradleProvisioner.defaultP2CacheDirectory(spotless.project))); + // One DedupingP2Provisioner for the whole build (not per-project). Parallel + // multi-project fingerprinting of eclipse()/greclipse() steps otherwise races + // on Solstice's on-disk P2 cache — Gradle then reports + // "ConfigurationCacheHackList cannot be serialized" (#3004). + return sharedP2Provisioner(spotless.project); } } } + private GradleProvisioner.DedupingP2Provisioner sharedP2Provisioner(Project project) { + GradleProvisioner.DedupingP2Provisioner local = sharedP2Provisioner; + if (local == null) { + synchronized (this) { + local = sharedP2Provisioner; + if (local == null) { + local = new GradleProvisioner.DedupingP2Provisioner( + P2Provisioner.createDefault(), + GradleProvisioner.defaultP2CacheDirectory(project)); + sharedP2Provisioner = local; + } + } + } + return local; + } + void registerSourceAlreadyRan(SpotlessTask task) { source.put(task.getPath(), task); } diff --git a/plugin-maven/CHANGES.md b/plugin-maven/CHANGES.md index d87cc8bedc..079c99d357 100644 --- a/plugin-maven/CHANGES.md +++ b/plugin-maven/CHANGES.md @@ -3,6 +3,8 @@ We adhere to the [keepachangelog](https://keepachangelog.com/en/1.0.0/) format (starting after version `1.27.0`). ## [Unreleased] +### Fixed +- Concurrent P2 provisioning no longer races Solstice's on-disk cache (affects Eclipse-based formatters under parallel builds). ([#3004](https://github.com/diffplug/spotless/issues/3004)) ## [3.9.0] - 2026-07-27 ### Added diff --git a/testlib/src/test/java/com/diffplug/spotless/ConfigurationCacheHackListTest.java b/testlib/src/test/java/com/diffplug/spotless/ConfigurationCacheHackListTest.java new file mode 100644 index 0000000000..ba7821e279 --- /dev/null +++ b/testlib/src/test/java/com/diffplug/spotless/ConfigurationCacheHackListTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2024-2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.spotless; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +class ConfigurationCacheHackListTest { + + /** Step whose equality/hashCode/serialization forces state evaluation. */ + private static FormatterStep lazyStep(String name, AtomicInteger stateEvals, Serializable state) { + return FormatterStep.createLazy(name, + () -> { + stateEvals.incrementAndGet(); + return state; + }, + SerializedFunction.identity(), + eq -> (FormatterFunc) (s -> s)); + } + + @Test + void toStringDoesNotEvaluateStepState() { + AtomicInteger evals = new AtomicInteger(); + ConfigurationCacheHackList list = ConfigurationCacheHackList.forEquality(); + list.addAll(List.of(lazyStep("expensive", evals, "state"))); + + // Gradle includes this value in "cannot be serialized" error messages. + // Default Object.toString() calls hashCode(), which fingerprints steps and + // may provision P2 deps — re-triggering the failure being reported (#3004). + String text = list.toString(); + assertThat(text).contains("ConfigurationCacheHackList"); + assertThat(text).contains("optimizeForEquality=true"); + assertThat(text).contains("size=1"); + assertThat(evals.get()).as("toString must not evaluate step state").isZero(); + } + + @Test + void equalityListRoundtripsThroughJavaSerialization() throws Exception { + AtomicInteger evals = new AtomicInteger(); + ConfigurationCacheHackList original = ConfigurationCacheHackList.forEquality(); + original.addAll(List.of(lazyStep("plain", evals, "eq-state"))); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(original); + } + assertThat(evals.get()).as("serializing equality list evaluates state once").isEqualTo(1); + + ConfigurationCacheHackList restored; + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + restored = (ConfigurationCacheHackList) in.readObject(); + } + assertThat(restored.getSteps()).hasSize(1); + assertThat(restored.getSteps().get(0).getName()).isEqualTo("plain"); + // toString after restore must still be side-effect free + assertThatCode(restored::toString).doesNotThrowAnyException(); + } +}