Parallelized mod builds

- Mod targets run through bounded Gradle workers with separate caches and daemon slots
- Target logs retain verification failures and bounded output summaries
- Nested tests use bounded forks and JVM thread counts
- Lifecycle tests control deadlines through injected clocks
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-21 02:53:00 -04:00
1 parent d7059d84a4
commit fc810221bb
17 files changed
+447 -61

No files matched your search

+12 -1
View File
@@ -17,6 +17,7 @@
*/ */
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import net.minecraftforge.gradle.shadow.net.minecraftforge.gradleutils.shared.EnhancedPlugin
import org.gradle.api.file.DeleteSpec import org.gradle.api.file.DeleteSpec
import org.gradle.api.file.FileCopyDetails import org.gradle.api.file.FileCopyDetails
import org.gradle.api.file.FileSystemOperations import org.gradle.api.file.FileSystemOperations
@@ -62,6 +63,14 @@ file('../../gradle.properties').withInputStream { InputStream stream -> rootProp
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.5')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.5'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(irisModTarget.forge.toString()) String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(irisModTarget.forge.toString())
String buildLane = gradle.extensions.extraProperties.has('irisBuildLane')
? gradle.extensions.extraProperties.get('irisBuildLane').toString() : null
if (buildLane != null) {
plugins.withType(EnhancedPlugin).configureEach { EnhancedPlugin<?> forgePlugin ->
forgePlugin.globalCaches().set(layout.projectDirectory.dir(".gradle/iris-lanes/${buildLane}/forgegradle"))
}
minecraft.mavenizerOutput.set(layout.projectDirectory.dir(".gradle/iris-lanes/${buildLane}/mavenizer/repo"))
}
Closure<String> loaderDisplayVersion = { String loaderVersion -> Closure<String> loaderDisplayVersion = { String loaderVersion ->
String coordinatePrefix = "${minecraftVersion}-" String coordinatePrefix = "${minecraftVersion}-"
if (loaderVersion.startsWith(coordinatePrefix)) { if (loaderVersion.startsWith(coordinatePrefix)) {
@@ -161,7 +170,9 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
} }
dependencies { dependencies {
implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}") implementation(buildLane == null
? minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
: minecraft.dependency("iris-${buildLane}", "net.minecraftforge:forge:${forgeVersion}"))
testImplementation('junit:junit:4.13.2') testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0') testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core')) testImplementation(testFixtures('art.arcane:core'))
@@ -30,6 +30,7 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.ModdedGenPool; import art.arcane.iris.modded.ModdedGenPool;
import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.Mantle;
import java.util.Objects;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.Semaphore; import java.util.concurrent.Semaphore;
@@ -39,6 +40,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
public final class ModdedPregenMethod implements PregeneratorMethod { public final class ModdedPregenMethod implements PregeneratorMethod {
private static final int ADAPTIVE_TIMEOUT_STEP = 3; private static final int ADAPTIVE_TIMEOUT_STEP = 3;
@@ -68,8 +70,14 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
private final int timeoutSeconds; private final int timeoutSeconds;
private final PregenMantleBackpressure backpressure; private final PregenMantleBackpressure backpressure;
private final PauseWhenEmptyGuard pauseGuard; private final PauseWhenEmptyGuard pauseGuard;
private final LongSupplier clock;
public ModdedPregenMethod(Configuration configuration) { public ModdedPregenMethod(Configuration configuration) {
this(configuration, System::nanoTime);
}
ModdedPregenMethod(Configuration configuration, LongSupplier clock) {
this.clock = Objects.requireNonNull(clock);
runtime = configuration.runtime(); runtime = configuration.runtime();
engine = configuration.engine(); engine = configuration.engine();
sync = configuration.sync(); sync = configuration.sync();
@@ -178,7 +186,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
if (sync || runtime.isServerThread()) { if (sync || runtime.isServerThread()) {
return; return;
} }
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) long deadline = clock.getAsLong() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
+ TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS); + TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS);
boolean interrupted = false; boolean interrupted = false;
try { try {
@@ -186,7 +194,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
if (finalSaveOnServerThread.get() || !runtime.isRunning()) { if (finalSaveOnServerThread.get() || !runtime.isRunning()) {
return; return;
} }
if (System.nanoTime() >= deadline) { if (clock.getAsLong() >= deadline) {
throw new IllegalStateException("Iris pregen chunk callbacks did not finish for " throw new IllegalStateException("Iris pregen chunk callbacks did not finish for "
+ runtime.worldIdentity() + ": " + inFlight.get() + " pending"); + runtime.worldIdentity() + ": " + inFlight.get() + " pending");
} }
@@ -263,14 +271,14 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
} }
private void awaitFinalSave(FinalSaveRequest request) { private void awaitFinalSave(FinalSaveRequest request) {
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS); long deadline = clock.getAsLong() + TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS);
boolean interrupted = false; boolean interrupted = false;
try { try {
while (true) { while (true) {
if (deferFinalSaveIfRequested()) { if (deferFinalSaveIfRequested()) {
return; return;
} }
long remainingNanos = deadline - System.nanoTime(); long remainingNanos = deadline - clock.getAsLong();
if (remainingNanos <= 0L) { if (remainingNanos <= 0L) {
ModdedIrisLog.warn("Iris pregen level save did not complete in time for {}", runtime.worldIdentity()); ModdedIrisLog.warn("Iris pregen level save did not complete in time for {}", runtime.worldIdentity());
return; return;
@@ -21,6 +21,7 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -97,6 +98,9 @@ public class ModdedPregenMethodLifecycleTest {
@Test @Test
public void finalSaveWaitsForEveryCallbackBeyondFiveSeconds() throws Exception { public void finalSaveWaitsForEveryCallbackBeyondFiveSeconds() throws Exception {
AtomicLong clock = new AtomicLong();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch advanced = new CountDownLatch(2);
CompletableFuture<ChunkLoad> load = new CompletableFuture<>(); CompletableFuture<ChunkLoad> load = new CompletableFuture<>();
CompletableFuture<ChunkLoad> failedLoad = new CompletableFuture<>(); CompletableFuture<ChunkLoad> failedLoad = new CompletableFuture<>();
PregenListener listener = mock(PregenListener.class); PregenListener listener = mock(PregenListener.class);
@@ -110,16 +114,22 @@ public class ModdedPregenMethodLifecycleTest {
}).when(runtime).execute(any()); }).when(runtime).execute(any());
try (MockedStatic<MantleHeapPressure> pressure = mockStatic(MantleHeapPressure.class)) { try (MockedStatic<MantleHeapPressure> pressure = mockStatic(MantleHeapPressure.class)) {
pressure.when(MantleHeapPressure::memoryBudgetBytes).thenReturn(512L << 20); pressure.when(MantleHeapPressure::memoryBudgetBytes).thenReturn(512L << 20);
method = new ModdedPregenMethod(new ModdedPregenMethod.Configuration(runtime, engine, false)); method = new ModdedPregenMethod(new ModdedPregenMethod.Configuration(runtime, engine, false), () -> {
long now = clock.get();
if (now >= TimeUnit.SECONDS.toNanos(6)) {
advanced.countDown();
} else {
started.countDown();
}
return now;
});
method.generateChunk(2, 3, listener); method.generateChunk(2, 3, listener);
method.generateChunk(4, 5, listener); method.generateChunk(4, 5, listener);
CountDownLatch started = new CountDownLatch(1); Future<?> closing = executor.submit(method::close);
Future<?> closing = executor.submit(() -> {
started.countDown();
method.close();
});
assertTrue(started.await(5, TimeUnit.SECONDS)); assertTrue(started.await(5, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> closing.get(6, TimeUnit.SECONDS)); clock.set(TimeUnit.SECONDS.toNanos(6));
assertTrue(advanced.await(5, TimeUnit.SECONDS));
assertFalse(closing.isDone());
verify(runtime, never()).save(); verify(runtime, never()).save();
verify(engine, never()).syncGenerationHistory(); verify(engine, never()).syncGenerationHistory();
load.complete(new ChunkLoad(true, null)); load.complete(new ChunkLoad(true, null));
+71 -38
View File
@@ -329,7 +329,12 @@ List<Object> moddedSharedInputs = []
'gradle/mod-target.gradle', 'gradle/mod-target.gradle',
'gradle/mod-targets.json', 'gradle/mod-targets.json',
'gradle/runtime-cache.settings.gradle', 'gradle/runtime-cache.settings.gradle',
'gradle/modded-version-sources.gradle' 'gradle/nested-build.init.gradle',
'gradle/modded-version-sources.gradle',
'gradle/wrapper/gradle-wrapper.properties',
'gradle/wrapper/gradle-wrapper.jar',
'gradlew',
'gradlew.bat'
].each { String path -> moddedSharedInputs.add(layout.projectDirectory.file(path).asFile) } ].each { String path -> moddedSharedInputs.add(layout.projectDirectory.file(path).asFile) }
if (localVolmLibCheckout != null) { if (localVolmLibCheckout != null) {
moddedSharedInputs.add(fileTree(localVolmLibCheckout) { moddedSharedInputs.add(fileTree(localVolmLibCheckout) {
@@ -343,11 +348,11 @@ if (localVolmLibCheckout != null) {
// The published master-SNAPSHOT is resolved inside the nested build, so no outer input changes when it // The published master-SNAPSHOT is resolved inside the nested build, so no outer input changes when it
// moves. Without a local checkout the Exec tasks always run and the nested build decides what is stale. // moves. Without a local checkout the Exec tasks always run and the nested build decides what is stale.
Closure<Void> declareVolmLibInputs = { Exec task -> Closure<Void> declareVolmLibInputs = { IrisModdedBuild task ->
task.inputs.property('volmLibStamp', volmLibStamp) task.inputs.property('volmLibStamp', volmLibStamp)
String nativeBillow = providers.gradleProperty('includeNativeBillow').getOrElse('false') String nativeBillow = providers.gradleProperty('includeNativeBillow').getOrElse('false')
task.inputs.property('includeNativeBillow', nativeBillow) task.inputs.property('includeNativeBillow', nativeBillow)
task.environment('ORG_GRADLE_PROJECT_includeNativeBillow', nativeBillow) task.environment.put('ORG_GRADLE_PROJECT_includeNativeBillow', nativeBillow)
if (localVolmLibCheckout == null) { if (localVolmLibCheckout == null) {
task.outputs.upToDateWhen { false } task.outputs.upToDateWhen { false }
} }
@@ -413,9 +418,29 @@ TaskProvider<Task> verifyModdedMatrix = tasks.register('verifyModdedMatrix') {
description = 'Builds, tests and verifies the Fabric, Forge and NeoForge jars of every supported Minecraft mod target.' description = 'Builds, tests and verifies the Fabric, Forge and NeoForge jars of every supported Minecraft mod target.'
} }
List<TaskProvider<Copy>> moddedExports = [] List<TaskProvider<Copy>> moddedExports = []
Map<String, TaskProvider<Exec>> selectedModdedBuilds = [:] Map<String, TaskProvider<IrisModdedBuild>> selectedModdedBuilds = [:]
Map<String, TaskProvider<Copy>> selectedModdedExports = [:] Map<String, TaskProvider<Copy>> selectedModdedExports = [:]
TaskProvider<Exec> previousMatrixBuild = null int matrixParallelism = Math.min(gradle.startParameter.maxWorkerCount, Math.max(1,
providers.gradleProperty('moddedBuildParallelism').getOrElse(
Math.min(12, Math.max(1, Runtime.runtime.availableProcessors() - 4)).toString()).toInteger()))
Provider<IrisModdedBuildService> matrixWorkers = gradle.sharedServices.registerIfAbsent(
'irisModdedBuilds', IrisModdedBuildService) {
maxParallelUsages.set(matrixParallelism)
}
List<String> nestedBuildArguments = [gradle.startParameter.buildCacheEnabled ? '--build-cache' : '--no-build-cache']
String nestedJvmArguments = providers.gradleProperty('org.gradle.jvmargs').getOrElse('') +
' -XX:ActiveProcessorCount=2'
if (gradle.startParameter.rerunTasks) {
nestedBuildArguments.add('--rerun-tasks')
}
if (gradle.startParameter.refreshDependencies) {
nestedBuildArguments.add('--refresh-dependencies')
}
for (File initScript : gradle.startParameter.initScripts) {
nestedBuildArguments.addAll(['--init-script', initScript.absolutePath])
}
Map<Integer, TaskProvider<IrisModdedBuild>> daemonSlots = [:]
int nextDaemonSlot = 0
irisModTargets.each { String targetMinecraft, Map<String, Object> target -> irisModTargets.each { String targetMinecraft, Map<String, Object> target ->
String suffix = targetMinecraft.replace('.', '_') String suffix = targetMinecraft.replace('.', '_')
boolean selected = targetMinecraft == minecraftVersion boolean selected = targetMinecraft == minecraftVersion
@@ -426,46 +451,66 @@ irisModTargets.each { String targetMinecraft, Map<String, Object> target ->
String artifactName = irisArtifactName(label, "${targetMinecraft}+${displayedLoader}") String artifactName = irisArtifactName(label, "${targetMinecraft}+${displayedLoader}")
File artifact = file("adapters/${loader}/build/${targetMinecraft}/libs/${artifactName}") File artifact = file("adapters/${loader}/build/${targetMinecraft}/libs/${artifactName}")
boolean unitTests = ((List<String>) (target.testLoaders ?: moddedLoaderLabels.keySet().toList())).contains(loader) boolean unitTests = ((List<String>) (target.testLoaders ?: moddedLoaderLabels.keySet().toList())).contains(loader)
TaskProvider<Exec> buildTarget = tasks.register("verify${label}${suffix}", Exec) { int daemonSlot = nextDaemonSlot++ % matrixParallelism
TaskProvider<IrisModdedBuild> buildTarget = project(':adapters').tasks.register("verify${label}${suffix}", IrisModdedBuild) {
group = 'verification' group = 'verification'
description = "Builds, tests and verifies the ${label} ${targetMinecraft} jar in its own Gradle build." description = "Builds, tests and verifies the ${label} ${targetMinecraft} jar in its own Gradle build."
workingDir = file("adapters/${loader}") usesService(matrixWorkers)
List<String> command = [file(moddedWrapperScript).absolutePath, 'assemble', 'compileTestJava', dependsOn(project(':core').tasks.named('jar'), project(':core').tasks.named('testFixturesJar'))
workingDirectory.set(rootProject.file("adapters/${loader}"))
getLabel().set("${label} ${targetMinecraft}".toString())
artifactFile.set(artifact)
logFile.set(rootProject.layout.buildDirectory.file("reports/modded/${loader}-${targetMinecraft}.log"))
requiredEntries.set(commonModdedArtifactEntries + moddedLoaderEntries[loader])
forbiddenCoreClasses.set(bukkitCoupledCoreClasses)
javaRuntime.set((int) target.java)
List<String> command = [rootProject.file(moddedWrapperScript).absolutePath, 'assemble', 'compileTestJava',
'verifyLoggingPolicy', 'verifyNativeClassBoundary', '--console=plain', 'verifyLoggingPolicy', 'verifyNativeClassBoundary', '--console=plain',
"-PirisVersion=${project.version}", "-PminecraftVersion=${targetMinecraft}", "--max-workers=${Math.min(2, gradle.startParameter.maxWorkerCount)}",
"-Dorg.gradle.jvmargs=${nestedJvmArguments.trim()}",
"-Dorg.gradle.daemon.registry.base=${rootProject.file(".gradle/iris-daemons/slot-${daemonSlot}").absolutePath}",
'-Dorg.gradle.daemon.idletimeout=3600000',
"-PirisBuildLane=${loader}-${targetMinecraft}",
'--project-cache-dir', rootProject.file("adapters/${loader}/.gradle/iris-lanes/${loader}-${targetMinecraft}").absolutePath,
'--init-script', rootProject.file('gradle/nested-build.init.gradle').absolutePath,
"-PirisVersion=${rootProject.version}", "-PminecraftVersion=${targetMinecraft}",
"-P${moddedLoaderProperties[loader]}=${loaderPin}", "-P${moddedLoaderProperties[loader]}=${loaderPin}",
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"] "-PuseLocalVolmLib=${localVolmLibCheckout != null}"]
if (unitTests) { if (unitTests) {
command.add('test') command.add('test')
} }
commandLine(command) command.add("-PtestForks=${providers.gradleProperty('moddedTestForks').getOrElse('1')}")
command.addAll(nestedBuildArguments)
commandLine.set(command.collect { Object argument -> argument.toString() })
if (localVolmLibCheckout != null) { if (localVolmLibCheckout != null) {
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath) environment.put('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
} }
inputs.files(moddedSharedInputs).withPropertyName('moddedSharedSources') inputs.files(moddedSharedInputs).withPropertyName('moddedSharedSources')
.withPathSensitivity(PathSensitivity.RELATIVE) .withPathSensitivity(PathSensitivity.RELATIVE)
inputs.files(file("adapters/${loader}/src"), file("adapters/${loader}/build.gradle"), inputs.files(rootProject.file("adapters/${loader}/src"), rootProject.file("adapters/${loader}/build.gradle"),
file("adapters/${loader}/settings.gradle")).withPropertyName('adapterSources') rootProject.file("adapters/${loader}/settings.gradle"), rootProject.file("adapters/${loader}/gradle.properties"))
.withPropertyName('adapterSources')
inputs.files(gradle.startParameter.allInitScripts).withPropertyName('initScripts')
.withPathSensitivity(PathSensitivity.NONE)
outputs.upToDateWhen { !gradle.startParameter.refreshDependencies }
inputs.properties(target) inputs.properties(target)
inputs.property('loaderVersion', loaderPin) inputs.property('loaderVersion', loaderPin)
inputs.property('irisVersion', project.version.toString()) inputs.property('irisVersion', rootProject.version.toString())
declareVolmLibInputs(it) declareVolmLibInputs(it)
outputs.file(artifact)
if (unitTests) { if (unitTests) {
outputs.dir(file("adapters/${loader}/build/${targetMinecraft}/test-results/test")) outputs.dir(rootProject.file("adapters/${loader}/build/${targetMinecraft}/test-results/test"))
}
doLast {
ModdedArtifactVerifier.verify(artifact, commonModdedArtifactEntries + moddedLoaderEntries[loader],
bukkitCoupledCoreClasses)
ModdedArtifactVerifier.verifyJavaRuntime(artifact, (int) target.java)
logger.lifecycle("Verified ${artifact.name}")
} }
} }
if (previousMatrixBuild != null) { TaskProvider<IrisModdedBuild> previousSlotBuild = daemonSlots[daemonSlot]
TaskProvider<Exec> preceding = previousMatrixBuild if (previousSlotBuild != null) {
buildTarget.configure { mustRunAfter(preceding) } buildTarget.configure { mustRunAfter(previousSlotBuild) }
}
daemonSlots[daemonSlot] = buildTarget
tasks.register("verify${label}${suffix}") {
group = 'verification'
description = "Builds, tests and verifies the ${label} ${targetMinecraft} jar."
dependsOn(buildTarget)
} }
previousMatrixBuild = buildTarget
verifyModdedMatrix.configure { dependsOn(buildTarget) } verifyModdedMatrix.configure { dependsOn(buildTarget) }
File legacyOutJar = new File(pluginOutDirectory, "Iris-${project.version}+mc${targetMinecraft}-${loader}.jar") File legacyOutJar = new File(pluginOutDirectory, "Iris-${project.version}+mc${targetMinecraft}-${loader}.jar")
FileTree staleTargetJars = fileTree(pluginOutDirectory) { FileTree staleTargetJars = fileTree(pluginOutDirectory) {
@@ -512,18 +557,6 @@ tasks.register('buildNeoforge') {
dependsOn(selectedModdedExports.neoforge) dependsOn(selectedModdedExports.neoforge)
} }
gradle.projectsEvaluated {
List<Task> sharedOutputTasks = allprojects.collectMany { Project candidate ->
candidate.tasks.findAll { Task task ->
task instanceof JavaCompile || task instanceof Jar || task instanceof Test
}
}
tasks.withType(Exec).matching { Task task -> task.name.startsWith('verify') }.configureEach {
mustRunAfter(sharedOutputTasks)
mustRunAfter(tasks.named('buildBukkit'))
}
}
tasks.named('check').configure { tasks.named('check').configure {
dependsOn(selectedModdedBuilds.fabric) dependsOn(selectedModdedBuilds.fabric)
} }
+4 -1
View File
@@ -21,11 +21,14 @@ repositories {
// Nothing else asks buildSrc for `test`: Gradle only requests the jar that carries the build logic. // Nothing else asks buildSrc for `test`: Gradle only requests the jar that carries the build logic.
// Gating the jar on the tests is what makes them run at all, and it costs nothing once they are green. // Gating the jar on the tests is what makes them run at all, and it costs nothing once they are green.
tasks.named('jar').configure { tasks.named('jar').configure {
dependsOn(tasks.named('test')) if (!gradle.extensions.extraProperties.has('irisBuildLane')) {
dependsOn(tasks.named('test'))
}
} }
tasks.named('test').configure { tasks.named('test').configure {
jvmArgs('--add-opens=java.base/java.lang=ALL-UNNAMED') jvmArgs('--add-opens=java.base/java.lang=ALL-UNNAMED')
jvmArgs('-XX:ParallelGCThreads=2', '-XX:ConcGCThreads=1', '-XX:CICompilerCount=2')
} }
dependencies { dependencies {
@@ -0,0 +1,67 @@
import org.gradle.api.DefaultTask;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.SetProperty;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.LocalState;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import org.gradle.workers.WorkerExecutor;
import javax.inject.Inject;
import java.util.Map;
public abstract class IrisModdedBuild extends DefaultTask {
public IrisModdedBuild() {
getEnvironment().convention(Map.of());
}
@Input
public abstract Property<String> getLabel();
@Internal
public abstract DirectoryProperty getWorkingDirectory();
@Input
public abstract ListProperty<String> getCommandLine();
@Input
public abstract MapProperty<String, String> getEnvironment();
@Input
public abstract ListProperty<String> getRequiredEntries();
@Input
public abstract SetProperty<String> getForbiddenCoreClasses();
@Input
public abstract Property<Integer> getJavaRuntime();
@OutputFile
public abstract RegularFileProperty getArtifactFile();
@LocalState
public abstract RegularFileProperty getLogFile();
@Inject
protected abstract WorkerExecutor getWorkerExecutor();
@TaskAction
public void build() {
getWorkerExecutor().noIsolation().submit(IrisModdedBuildAction.class, parameters -> {
parameters.getLabel().set(getLabel());
parameters.getWorkingDirectory().set(getWorkingDirectory());
parameters.getCommandLine().set(getCommandLine());
parameters.getEnvironment().set(getEnvironment());
parameters.getRequiredEntries().set(getRequiredEntries());
parameters.getForbiddenCoreClasses().set(getForbiddenCoreClasses());
parameters.getJavaRuntime().set(getJavaRuntime());
parameters.getArtifactFile().set(getArtifactFile());
parameters.getLogFile().set(getLogFile());
});
}
}
@@ -0,0 +1,85 @@
import org.gradle.api.GradleException;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.process.ExecOperations;
import org.gradle.workers.WorkAction;
import javax.inject.Inject;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
public abstract class IrisModdedBuildAction implements WorkAction<IrisModdedBuildParameters> {
private static final Logger LOGGER = Logging.getLogger(IrisModdedBuildAction.class);
private static final int LOG_TAIL_BYTES = 16 * 1024;
private static final int LOG_TAIL_LINES = 40;
@Inject
protected abstract ExecOperations getExecOperations();
@Override
public void execute() {
IrisModdedBuildParameters parameters = getParameters();
String label = parameters.getLabel().get();
Path log = parameters.getLogFile().get().getAsFile().toPath();
long started = System.nanoTime();
LOGGER.lifecycle("{} started; log: {}", label, log);
try {
Files.createDirectories(log.getParent());
try (OutputStream output = Files.newOutputStream(log)) {
getExecOperations().exec(spec -> {
spec.setWorkingDir(parameters.getWorkingDirectory().get().getAsFile());
spec.commandLine(parameters.getCommandLine().get());
spec.environment(parameters.getEnvironment().get());
spec.setStandardOutput(output);
spec.setErrorOutput(output);
}).assertNormalExitValue();
}
ModdedArtifactVerifier.verify(parameters.getArtifactFile().get().getAsFile(),
parameters.getRequiredEntries().get(), parameters.getForbiddenCoreClasses().get());
ModdedArtifactVerifier.verifyJavaRuntime(parameters.getArtifactFile().get().getAsFile(),
parameters.getJavaRuntime().get());
LOGGER.lifecycle("{} succeeded in {} ms", label,
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started));
} catch (Exception failure) {
appendFailure(log, failure);
String details;
try {
details = logTail(log);
} catch (IOException unreadable) {
failure.addSuppressed(unreadable);
details = "Log could not be read.";
}
LOGGER.error(label + " failed; log: " + log + System.lineSeparator() + details, failure);
throw new GradleException(label + " failed; log: " + log, failure);
}
}
static String logTail(Path log) throws IOException {
try (RandomAccessFile input = new RandomAccessFile(log.toFile(), "r")) {
long offset = Math.max(0L, input.length() - LOG_TAIL_BYTES);
input.seek(offset);
byte[] bytes = new byte[(int) (input.length() - offset)];
input.readFully(bytes);
String[] lines = new String(bytes, StandardCharsets.UTF_8).split("\\R");
return String.join(System.lineSeparator(), Arrays.copyOfRange(lines,
Math.max(0, lines.length - LOG_TAIL_LINES), lines.length));
}
}
private static void appendFailure(Path log, Exception failure) {
try (PrintWriter output = new PrintWriter(Files.newBufferedWriter(log, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND))) {
failure.printStackTrace(output);
} catch (IOException unwritable) {
failure.addSuppressed(unwritable);
}
}
}
@@ -0,0 +1,27 @@
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.SetProperty;
import org.gradle.workers.WorkParameters;
public interface IrisModdedBuildParameters extends WorkParameters {
Property<String> getLabel();
DirectoryProperty getWorkingDirectory();
ListProperty<String> getCommandLine();
MapProperty<String, String> getEnvironment();
ListProperty<String> getRequiredEntries();
SetProperty<String> getForbiddenCoreClasses();
Property<Integer> getJavaRuntime();
RegularFileProperty getArtifactFile();
RegularFileProperty getLogFile();
}
@@ -0,0 +1,5 @@
import org.gradle.api.services.BuildService;
import org.gradle.api.services.BuildServiceParameters;
public abstract class IrisModdedBuildService implements BuildService<BuildServiceParameters.None> {
}
@@ -0,0 +1,79 @@
import org.gradle.api.Project;
import org.gradle.testfixtures.ProjectBuilder;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisModdedBuildTest {
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
@Test
public void capturesTheCompleteTargetConfiguration() throws Exception {
Project project = ProjectBuilder.builder().withProjectDir(temporary.newFolder()).build();
IrisModdedBuild task = project.getTasks().create("verifyFabric", IrisModdedBuild.class);
task.getLabel().set("Fabric 26.3");
task.getWorkingDirectory().set(project.getLayout().getProjectDirectory());
task.getCommandLine().set(List.of("gradlew", "assemble", "test"));
task.getRequiredEntries().set(List.of("fabric.mod.json"));
task.getForbiddenCoreClasses().set(Set.of("org/bukkit/Test"));
task.getJavaRuntime().set(25);
task.getArtifactFile().set(project.getLayout().getProjectDirectory().file("build/mod.jar"));
task.getLogFile().set(project.getLayout().getProjectDirectory().file("build/reports/matrix/fabric.log"));
assertEquals(Map.of(), task.getEnvironment().get());
assertEquals(List.of("gradlew", "assemble", "test"), task.getCommandLine().get());
assertEquals(List.of("fabric.mod.json"), task.getRequiredEntries().get());
assertEquals(Set.of("org/bukkit/Test"), task.getForbiddenCoreClasses().get());
assertEquals(Integer.valueOf(25), task.getJavaRuntime().get());
assertEquals(Set.of(task.getArtifactFile().get().getAsFile()), task.getOutputs().getFiles().getFiles());
assertFalse(task.getOutputs().getFiles().contains(task.getLogFile().get().getAsFile()));
}
@Test
public void failureSummaryRetainsTheFinalLinesWithoutReadingAnEntireLog() throws Exception {
Path log = temporary.newFile().toPath();
StringBuilder content = new StringBuilder("discarded first line\n");
for (int index = 0; index < 2000; index++) {
content.append("task output ").append(index).append('\n');
}
content.append("final failure\n");
Files.writeString(log, content);
String tail = IrisModdedBuildAction.logTail(log);
assertEquals(40, tail.lines().count());
assertTrue(tail.endsWith("final failure"));
assertFalse(tail.contains("discarded first line"));
assertTrue(tail.length() <= 16 * 1024);
}
@Test
public void failureSummaryAcceptsEmptyAndUnterminatedLogs() throws Exception {
Path log = temporary.newFile().toPath();
assertEquals("", IrisModdedBuildAction.logTail(log));
Files.writeString(log, "first\r\nlast");
assertEquals("first" + System.lineSeparator() + "last", IrisModdedBuildAction.logTail(log));
}
@Test
public void failureSummaryBoundsAnUnbrokenFailureLine() throws Exception {
Path log = temporary.newFile().toPath();
Files.writeString(log, "x".repeat(100_000) + "final failure");
String tail = IrisModdedBuildAction.logTail(log);
assertEquals(16 * 1024, tail.length());
assertTrue(tail.endsWith("final failure"));
}
}
@@ -26,6 +26,7 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.IdentityHashMap; import java.util.IdentityHashMap;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
@@ -33,6 +34,7 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.LongSupplier;
import static art.arcane.iris.generation.runtime.EngineShutdownSequence.appendFailure; import static art.arcane.iris.generation.runtime.EngineShutdownSequence.appendFailure;
import static art.arcane.iris.generation.runtime.EngineShutdownSequence.propagate; import static art.arcane.iris.generation.runtime.EngineShutdownSequence.propagate;
@@ -47,9 +49,18 @@ final class EngineBackgroundTasks {
private final Object backgroundTaskLock = new Object(); private final Object backgroundTaskLock = new Object();
private final List<TrackedBackgroundTask> backgroundTasks = new ArrayList<>(); private final List<TrackedBackgroundTask> backgroundTasks = new ArrayList<>();
private final LongSupplier clock;
private boolean backgroundTaskAdmission; private boolean backgroundTaskAdmission;
private int activeDrains; private int activeDrains;
EngineBackgroundTasks() {
this(System::nanoTime);
}
EngineBackgroundTasks(LongSupplier clock) {
this.clock = Objects.requireNonNull(clock);
}
boolean scheduleTrackedTask(Runnable task) { boolean scheduleTrackedTask(Runnable task) {
return scheduleTrackedTask(task, false); return scheduleTrackedTask(task, false);
} }
@@ -106,7 +117,7 @@ final class EngineBackgroundTasks {
} }
BackgroundTaskDrain drainBackgroundTasks(String reason) { BackgroundTaskDrain drainBackgroundTasks(String reason) {
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(BACKGROUND_TASK_TIMEOUT_MILLIS); long deadline = clock.getAsLong() + TimeUnit.MILLISECONDS.toNanos(BACKGROUND_TASK_TIMEOUT_MILLIS);
Set<TrackedBackgroundTask> observed = Collections.newSetFromMap(new IdentityHashMap<>()); Set<TrackedBackgroundTask> observed = Collections.newSetFromMap(new IdentityHashMap<>());
Set<TrackedBackgroundTask> settledAtEntry = Collections.newSetFromMap(new IdentityHashMap<>()); Set<TrackedBackgroundTask> settledAtEntry = Collections.newSetFromMap(new IdentityHashMap<>());
List<TrackedBackgroundTask> tasks; List<TrackedBackgroundTask> tasks;
@@ -135,7 +146,7 @@ final class EngineBackgroundTasks {
return new BackgroundTaskDrain(failure, allTasksComplete()); return new BackgroundTaskDrain(failure, allTasksComplete());
} }
} }
if (System.nanoTime() >= deadline) { if (clock.getAsLong() >= deadline) {
for (TrackedBackgroundTask task : tasks) { for (TrackedBackgroundTask task : tasks) {
cancelBackgroundTask(task, reason); cancelBackgroundTask(task, reason);
} }
@@ -170,7 +181,7 @@ final class EngineBackgroundTasks {
long deadline, String reason) { long deadline, String reason) {
Throwable failure = null; Throwable failure = null;
for (TrackedBackgroundTask task : tasks) { for (TrackedBackgroundTask task : tasks) {
long remaining = deadline - System.nanoTime(); long remaining = deadline - clock.getAsLong();
if (remaining <= 0L) { if (remaining <= 0L) {
cancelBackgroundTask(task, reason); cancelBackgroundTask(task, reason);
failure = appendFailure(failure, new TimeoutException("Timed out waiting for Iris background tasks during " + reason + ".")); failure = appendFailure(failure, new TimeoutException("Timed out waiting for Iris background tasks during " + reason + "."));
@@ -14,6 +14,7 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport; import java.util.concurrent.locks.LockSupport;
@@ -115,7 +116,8 @@ public class EngineBackgroundTasksAdmissionTest {
@Test @Test
public void continuationsShareTheOriginalDrainDeadline() throws Exception { public void continuationsShareTheOriginalDrainDeadline() throws Exception {
Fixture fixture = fixture(); AtomicLong clock = new AtomicLong();
Fixture fixture = fixture(new EngineBackgroundTasks(clock::get));
CountDownLatch firstEntered = new CountDownLatch(1); CountDownLatch firstEntered = new CountDownLatch(1);
CountDownLatch firstRelease = new CountDownLatch(1); CountDownLatch firstRelease = new CountDownLatch(1);
CountDownLatch saveEntered = new CountDownLatch(1); CountDownLatch saveEntered = new CountDownLatch(1);
@@ -132,9 +134,9 @@ public class EngineBackgroundTasksAdmissionTest {
fixture.tasks().scheduleAdmittedTask(fixture.engine(), fixture.tasks().scheduleAdmittedTask(fixture.engine(),
() -> waitForRelease(saveEntered, saveRelease)))); () -> waitForRelease(saveEntered, saveRelease))));
assertTrue(saveEntered.await(5, TimeUnit.SECONDS)); assertTrue(saveEntered.await(5, TimeUnit.SECONDS));
TimeUnit.SECONDS.sleep(7); clock.set(TimeUnit.SECONDS.toNanos(15) - TimeUnit.MILLISECONDS.toNanos(1));
firstRelease.countDown(); firstRelease.countDown();
EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(10, TimeUnit.SECONDS); EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(5, TimeUnit.SECONDS);
assertTrue(result.failure() instanceof TimeoutException); assertTrue(result.failure() instanceof TimeoutException);
assertThrows(IllegalStateException.class, () -> result.requireComplete("Studio cutover")); assertThrows(IllegalStateException.class, () -> result.requireComplete("Studio cutover"));
} finally { } finally {
@@ -287,7 +289,10 @@ public class EngineBackgroundTasksAdmissionTest {
} }
private static Fixture fixture() { private static Fixture fixture() {
EngineBackgroundTasks tasks = new EngineBackgroundTasks(); return fixture(new EngineBackgroundTasks());
}
private static Fixture fixture(EngineBackgroundTasks tasks) {
tasks.openBackgroundTaskAdmission(); tasks.openBackgroundTaskAdmission();
GenerationSessionManager sessions = new GenerationSessionManager(true); GenerationSessionManager sessions = new GenerationSessionManager(true);
Engine engine = mock(Engine.class); Engine engine = mock(Engine.class);
+1
View File
@@ -17,6 +17,7 @@
# #
org.gradle.daemon=true org.gradle.daemon=true
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.workers.max=32
org.gradle.jvmargs=-Xmx3072m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx3072m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.caching=true org.gradle.caching=true
org.gradle.configureondemand=false org.gradle.configureondemand=false
@@ -11,11 +11,13 @@ if (coreBuild == null) {
throw new GradleException('The modded artifact requires the included Iris core build and its generation build revision.') throw new GradleException('The modded artifact requires the included Iris core build and its generation build revision.')
} }
TaskReference buildRevisionVerification = coreBuild.task(':core:verifyGenerationBuildRevision') TaskReference buildRevisionVerification = coreBuild.task(':core:verifyGenerationBuildRevision')
String buildLogicVariant = gradle.extensions.extraProperties.has('irisBuildLane')
? "iris-lanes/${gradle.extensions.extraProperties.get('irisBuildLane')}/" : ''
TaskProvider<JavaExec> resolvedRevisionVerification = tasks.register('verifyResolvedGenerationKernelDependencies', JavaExec) { TaskProvider<JavaExec> resolvedRevisionVerification = tasks.register('verifyResolvedGenerationKernelDependencies', JavaExec) {
group = 'verification' group = 'verification'
description = 'Checks the actual bundled generation library contents against the current build revision.' description = 'Checks the actual bundled generation library contents against the current build revision.'
dependsOn(buildRevisionVerification) dependsOn(buildRevisionVerification)
classpath = files('../../buildSrc/build/libs/buildSrc.jar') classpath = files("../../buildSrc/build/${buildLogicVariant}libs/buildSrc.jar")
mainClass.set('art.arcane.iris.buildtools.GenerationBuildRevision') mainClass.set('art.arcane.iris.buildtools.GenerationBuildRevision')
doFirst { doFirst {
String runtimeVariant = gradle.extensions.extraProperties.get('irisRuntimeOutputVariant').toString() String runtimeVariant = gradle.extensions.extraProperties.get('irisRuntimeOutputVariant').toString()
+6
View File
@@ -2,11 +2,17 @@ import groovy.json.JsonSlurper
List<String> sourceOverlays = (List<String>) irisModTarget.overlays List<String> sourceOverlays = (List<String>) irisModTarget.overlays
List<File> adapterSourceRoots = [projectDir, file('../modded-common'), file('../client-common')] List<File> adapterSourceRoots = [projectDir, file('../modded-common'), file('../client-common')]
int testForks = Math.max(1, Integer.parseInt(providers.gradleProperty('testForks').getOrElse('2')))
tasks.named('compileTestJava', JavaCompile).configure { tasks.named('compileTestJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java) options.release.set((int) irisModTarget.java)
} }
tasks.withType(Test).configureEach { tasks.withType(Test).configureEach {
maxParallelForks = Math.min(2, Math.min(Runtime.runtime.availableProcessors(),
Math.min(gradle.startParameter.maxWorkerCount, testForks)))
if (gradle.extensions.extraProperties.has('irisBuildLane')) {
jvmArgs('-XX:ParallelGCThreads=2', '-XX:ConcGCThreads=1', '-XX:CICompilerCount=2')
}
javaLauncher.set(javaToolchains.launcherFor { javaLauncher.set(javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of((int) irisModTarget.java) languageVersion = JavaLanguageVersion.of((int) irisModTarget.java)
}) })
+31
View File
@@ -0,0 +1,31 @@
Gradle rootBuild = gradle
while (rootBuild.parent != null) {
rootBuild = rootBuild.parent
}
String buildLane = rootBuild.startParameter.projectProperties['irisBuildLane']
if (buildLane == null) {
throw new GradleException('The nested build requires -PirisBuildLane.')
}
if (!(buildLane ==~ /[a-zA-Z0-9][a-zA-Z0-9._-]*/)) {
throw new GradleException('irisBuildLane must contain only letters, numbers, dots, underscores and hyphens.')
}
gradle.extensions.extraProperties.set('irisBuildLane', buildLane)
gradle.startParameter.projectCacheDir = new File(gradle.startParameter.currentDir, ".gradle/iris-lanes/${buildLane}")
gradle.beforeSettings { Settings settings ->
settings.gradle.startParameter.projectCacheDir = new File(settings.settingsDir, ".gradle/iris-lanes/${buildLane}")
}
gradle.beforeProject { Project candidate ->
boolean sharedRuntime = candidate.rootProject.name == 'Iris'
&& candidate.path in [':core', ':core:agent', ':spi']
if (!candidate.rootProject.name.startsWith('iris-') && !sharedRuntime) {
candidate.layout.buildDirectory.set(candidate.layout.projectDirectory.dir("build/iris-lanes/${buildLane}"))
}
candidate.plugins.withId('io.papermc.paperweight.userdev') {
candidate.tasks.named('paperweightUserdevSetup').configure { Task setup ->
Directory taskCache = candidate.layout.projectDirectory.dir(".gradle/iris-lanes/${buildLane}/caches/paperweight/taskCache")
setup.mappedServerJar.set(taskCache.file('mappedServerJar.jar'))
setup.reobfMappings.set(taskCache.file('reobfMappings.tiny'))
}
}
}
+4 -2
View File
@@ -11,8 +11,10 @@ if (modTarget == null) {
} }
String runtimeJava = providers.gradleProperty('irisRuntimeJava').getOrElse(modTarget.java.toString()) String runtimeJava = providers.gradleProperty('irisRuntimeJava').getOrElse(modTarget.java.toString())
String variant = adapter ? "minecraft${minecraftVersion}-java${runtimeJava}" : "java${runtimeJava}" String variant = adapter ? "minecraft${minecraftVersion}-java${runtimeJava}" : "java${runtimeJava}"
File projectCacheRoot = gradle.startParameter.projectCacheDir ?: new File(settingsDir, '.gradle') if (!gradle.extensions.extraProperties.has('irisBuildLane')) {
gradle.startParameter.projectCacheDir = new File(projectCacheRoot, variant) File projectCacheRoot = gradle.startParameter.projectCacheDir ?: new File(settingsDir, '.gradle')
gradle.startParameter.projectCacheDir = new File(projectCacheRoot, variant)
}
String runtimeOutputVariant = runtimeJava == '21' ? 'java21/' : '' String runtimeOutputVariant = runtimeJava == '21' ? 'java21/' : ''
if (adapter) { if (adapter) {