From 0203154b17dbeac3bfacd1bb92d4545f2eebdc81 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Mon, 6 Jul 2026 13:26:39 +0200 Subject: [PATCH 1/2] fix: prevent HackClone serialization race --- .../FormatterStepSerializationRoundtrip.java | 9 +- .../HackCloneRaceIntegrationTest.java | 119 ++++++++++++++++++ 2 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 plugin-gradle/src/test/java/com/diffplug/gradle/spotless/HackCloneRaceIntegrationTest.java diff --git a/lib/src/main/java/com/diffplug/spotless/FormatterStepSerializationRoundtrip.java b/lib/src/main/java/com/diffplug/spotless/FormatterStepSerializationRoundtrip.java index 15febb27ff..f6cf623f8b 100644 --- a/lib/src/main/java/com/diffplug/spotless/FormatterStepSerializationRoundtrip.java +++ b/lib/src/main/java/com/diffplug/spotless/FormatterStepSerializationRoundtrip.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 DiffPlug + * Copyright 2023-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -111,12 +111,13 @@ static class HackCloneThe original failure used: + * + *

+ * groovyGradle {
+ *     toggleOffOn()                // adds a FenceStep
+ *     target '**/*.gradle'     // no files matching this target
+ *     greclipse()                  // lazy slow step
+ * }
+ * 
+ * + *

{@code toggleOffOn()} creates a wrapper step that preserves text between the off/on markers and + * stores {@code greclipse()} in a lazy {@code FormatterStep} containing a nested + * {@code ConfigurationCacheHackList}. As no files match the target, the slow {@code greclipse} state + * is first initialized while Gradle fingerprints task inputs, before task actions format any files. + * + *

The race is not specific to {@code greclipse} formatting. It only needs a lazy {@code FormatterStep} + * whose state supplier stays inside {@code HackClone.writeObject} long enough for another Gradle + * worker to serialize the same half-built clone. {@code greclipse()} provided that delay by resolving + * Eclipse state. + * + *

The real build configures a separate {@code greclipse()} step for each subproject. This test + * deliberately reuses one local slow no-op wrapper step across subprojects so parallel Gradle workers + * serialize the same nested {@code HackClone}. That makes the concurrency window deterministic while + * avoiding Eclipse provisioning and still using Gradle's real input serialization path. + * + *

That shared-object setup is intentionally not an isolated-projects-compatible build pattern: + * isolated projects remove this kind of cross-project shared configuration state, while this + * reproducer needs it to force concurrent serialization of one {@code HackClone}. + */ +class HackCloneRaceIntegrationTest extends GradleIntegrationHarness { + private static final int SUBPROJECTS = 12; + private static final int ATTEMPTS = 3; + private static final int STATE_INIT_SLEEP_MS = 500; + + @Test + void realGradleParallelFingerprintingOfNestedLazyStepIsThreadSafe() throws IOException { + StringBuilder settings = new StringBuilder("rootProject.name = 'race'\n"); + for (int i = 0; i < SUBPROJECTS; i++) { + settings.append("include 'sub").append(i).append("'\n"); + setFile("sub" + i + "/.keep").toContent(""); + } + setFile("settings.gradle").toContent(settings.toString()); + setFile("gradle.properties").toContent("org.gradle.jvmargs=-Xmx1g\n"); + + setFile("build.gradle").toContent(String.join("\n", + "plugins { id 'com.diffplug.spotless' }", + "", + "class SlowStateSupplier implements com.diffplug.spotless.ThrowingEx.Supplier, Serializable {", + " private final long sleepMillis", + " SlowStateSupplier(long sleepMillis) { this.sleepMillis = sleepMillis }", + " String get() { Thread.sleep(sleepMillis); return 'state' }", + "}", + "", + "class NoopFormatter implements com.diffplug.spotless.FormatterFunc, Serializable {", + " String apply(String input) { return input }", + "}", + "", + "def hackCloneRaceStep", + "", + "subprojects {", + " apply plugin: 'com.diffplug.spotless'", + " spotless {", + " if (hackCloneRaceStep == null) {", + " def slowStep = com.diffplug.spotless.FormatterStep.createLazy(", + " 'slowLazy',", + " new SlowStateSupplier(" + STATE_INIT_SLEEP_MS + "L),", + " com.diffplug.spotless.SerializedFunction.identity(),", + " com.diffplug.spotless.SerializedFunction.alwaysReturns(new NoopFormatter()))", + " hackCloneRaceStep = com.diffplug.spotless.generic.FenceStep", + " .named('toggle')", + " .openClose('spotless:off', 'spotless:on')", + " .preserveWithin([slowStep])", + " }", + " format 'race', {", + " target 'no-such-dir/**/*.txt'", + " addStep(hackCloneRaceStep)", + " }", + " }", + "}")); + + for (int attempt = 0; attempt < ATTEMPTS; attempt++) { + BuildResult result = gradleRunner() + .withGradleVersion("9.6.1") + .withArguments("spotlessRace", "--parallel", "--build-cache", "--rerun-tasks", "--max-workers=" + SUBPROJECTS, "--stacktrace") + .build(); + assertThat(result.getOutput()) + .as("attempt %d must not trip the HackClone.writeObject guard", attempt) + .doesNotContain("roundtripStateInternal or equalityStateInternal should be non-null"); + } + } +} From 52c1161575a67a0707aac265a9c458867fd0ceef Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Mon, 6 Jul 2026 14:46:12 +0200 Subject: [PATCH 2/2] docs: add Gradle changelog entry --- plugin-gradle/CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin-gradle/CHANGES.md b/plugin-gradle/CHANGES.md index 9665b4068c..c204738a16 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 +- Prevent parallel Gradle input fingerprinting from failing when `toggleOffOn()` wraps a slow lazy formatter step with no matching target files. ([#2994](https://github.com/diffplug/spotless/pull/2994)) ## [8.8.0] - 2026-06-29 ### Added