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"); + } + } +}