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 net.minecraftforge.gradle.shadow.net.minecraftforge.gradleutils.shared.EnhancedPlugin
import org.gradle.api.file.DeleteSpec
import org.gradle.api.file.FileCopyDetails
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 minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
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 ->
String coordinatePrefix = "${minecraftVersion}-"
if (loaderVersion.startsWith(coordinatePrefix)) {
@@ -161,7 +170,9 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
}
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('org.mockito:mockito-core:5.23.0')
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.volmlib.util.mantle.runtime.Mantle;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
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.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
public final class ModdedPregenMethod implements PregeneratorMethod {
private static final int ADAPTIVE_TIMEOUT_STEP = 3;
@@ -68,8 +70,14 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
private final int timeoutSeconds;
private final PregenMantleBackpressure backpressure;
private final PauseWhenEmptyGuard pauseGuard;
private final LongSupplier clock;
public ModdedPregenMethod(Configuration configuration) {
this(configuration, System::nanoTime);
}
ModdedPregenMethod(Configuration configuration, LongSupplier clock) {
this.clock = Objects.requireNonNull(clock);
runtime = configuration.runtime();
engine = configuration.engine();
sync = configuration.sync();
@@ -178,7 +186,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
if (sync || runtime.isServerThread()) {
return;
}
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
long deadline = clock.getAsLong() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
+ TimeUnit.MILLISECONDS.toNanos(FINAL_SAVE_TIMEOUT_MILLIS);
boolean interrupted = false;
try {
@@ -186,7 +194,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
if (finalSaveOnServerThread.get() || !runtime.isRunning()) {
return;
}
if (System.nanoTime() >= deadline) {
if (clock.getAsLong() >= deadline) {
throw new IllegalStateException("Iris pregen chunk callbacks did not finish for "
+ runtime.worldIdentity() + ": " + inFlight.get() + " pending");
}
@@ -263,14 +271,14 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
}
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;
try {
while (true) {
if (deferFinalSaveIfRequested()) {
return;
}
long remainingNanos = deadline - System.nanoTime();
long remainingNanos = deadline - clock.getAsLong();
if (remainingNanos <= 0L) {
ModdedIrisLog.warn("Iris pregen level save did not complete in time for {}", runtime.worldIdentity());
return;
@@ -21,6 +21,7 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
@@ -97,6 +98,9 @@ public class ModdedPregenMethodLifecycleTest {
@Test
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> failedLoad = new CompletableFuture<>();
PregenListener listener = mock(PregenListener.class);
@@ -110,16 +114,22 @@ public class ModdedPregenMethodLifecycleTest {
}).when(runtime).execute(any());
try (MockedStatic<MantleHeapPressure> pressure = mockStatic(MantleHeapPressure.class)) {
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(4, 5, listener);
CountDownLatch started = new CountDownLatch(1);
Future<?> closing = executor.submit(() -> {
started.countDown();
method.close();
});
Future<?> closing = executor.submit(method::close);
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(engine, never()).syncGenerationHistory();
load.complete(new ChunkLoad(true, null));
+71 -38
View File
@@ -329,7 +329,12 @@ List<Object> moddedSharedInputs = []
'gradle/mod-target.gradle',
'gradle/mod-targets.json',
'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) }
if (localVolmLibCheckout != null) {
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
// 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)
String nativeBillow = providers.gradleProperty('includeNativeBillow').getOrElse('false')
task.inputs.property('includeNativeBillow', nativeBillow)
task.environment('ORG_GRADLE_PROJECT_includeNativeBillow', nativeBillow)
task.environment.put('ORG_GRADLE_PROJECT_includeNativeBillow', nativeBillow)
if (localVolmLibCheckout == null) {
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.'
}
List<TaskProvider<Copy>> moddedExports = []
Map<String, TaskProvider<Exec>> selectedModdedBuilds = [:]
Map<String, TaskProvider<IrisModdedBuild>> selectedModdedBuilds = [:]
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 ->
String suffix = targetMinecraft.replace('.', '_')
boolean selected = targetMinecraft == minecraftVersion
@@ -426,46 +451,66 @@ irisModTargets.each { String targetMinecraft, Map<String, Object> target ->
String artifactName = irisArtifactName(label, "${targetMinecraft}+${displayedLoader}")
File artifact = file("adapters/${loader}/build/${targetMinecraft}/libs/${artifactName}")
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'
description = "Builds, tests and verifies the ${label} ${targetMinecraft} jar in its own Gradle build."
workingDir = file("adapters/${loader}")
List<String> command = [file(moddedWrapperScript).absolutePath, 'assemble', 'compileTestJava',
usesService(matrixWorkers)
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',
"-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}",
"-PuseLocalVolmLib=${localVolmLibCheckout != null}"]
if (unitTests) {
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) {
environment('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
environment.put('ORG_GRADLE_PROJECT_localVolmLibDirectory', localVolmLibCheckout.absolutePath)
}
inputs.files(moddedSharedInputs).withPropertyName('moddedSharedSources')
.withPathSensitivity(PathSensitivity.RELATIVE)
inputs.files(file("adapters/${loader}/src"), file("adapters/${loader}/build.gradle"),
file("adapters/${loader}/settings.gradle")).withPropertyName('adapterSources')
inputs.files(rootProject.file("adapters/${loader}/src"), rootProject.file("adapters/${loader}/build.gradle"),
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.property('loaderVersion', loaderPin)
inputs.property('irisVersion', project.version.toString())
inputs.property('irisVersion', rootProject.version.toString())
declareVolmLibInputs(it)
outputs.file(artifact)
if (unitTests) {
outputs.dir(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}")
outputs.dir(rootProject.file("adapters/${loader}/build/${targetMinecraft}/test-results/test"))
}
}
if (previousMatrixBuild != null) {
TaskProvider<Exec> preceding = previousMatrixBuild
buildTarget.configure { mustRunAfter(preceding) }
TaskProvider<IrisModdedBuild> previousSlotBuild = daemonSlots[daemonSlot]
if (previousSlotBuild != null) {
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) }
File legacyOutJar = new File(pluginOutDirectory, "Iris-${project.version}+mc${targetMinecraft}-${loader}.jar")
FileTree staleTargetJars = fileTree(pluginOutDirectory) {
@@ -512,18 +557,6 @@ tasks.register('buildNeoforge') {
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 {
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.
// 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 {
dependsOn(tasks.named('test'))
if (!gradle.extensions.extraProperties.has('irisBuildLane')) {
dependsOn(tasks.named('test'))
}
}
tasks.named('test').configure {
jvmArgs('--add-opens=java.base/java.lang=ALL-UNNAMED')
jvmArgs('-XX:ParallelGCThreads=2', '-XX:ConcGCThreads=1', '-XX:CICompilerCount=2')
}
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.IdentityHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@@ -33,6 +34,7 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
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.propagate;
@@ -47,9 +49,18 @@ final class EngineBackgroundTasks {
private final Object backgroundTaskLock = new Object();
private final List<TrackedBackgroundTask> backgroundTasks = new ArrayList<>();
private final LongSupplier clock;
private boolean backgroundTaskAdmission;
private int activeDrains;
EngineBackgroundTasks() {
this(System::nanoTime);
}
EngineBackgroundTasks(LongSupplier clock) {
this.clock = Objects.requireNonNull(clock);
}
boolean scheduleTrackedTask(Runnable task) {
return scheduleTrackedTask(task, false);
}
@@ -106,7 +117,7 @@ final class EngineBackgroundTasks {
}
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> settledAtEntry = Collections.newSetFromMap(new IdentityHashMap<>());
List<TrackedBackgroundTask> tasks;
@@ -135,7 +146,7 @@ final class EngineBackgroundTasks {
return new BackgroundTaskDrain(failure, allTasksComplete());
}
}
if (System.nanoTime() >= deadline) {
if (clock.getAsLong() >= deadline) {
for (TrackedBackgroundTask task : tasks) {
cancelBackgroundTask(task, reason);
}
@@ -170,7 +181,7 @@ final class EngineBackgroundTasks {
long deadline, String reason) {
Throwable failure = null;
for (TrackedBackgroundTask task : tasks) {
long remaining = deadline - System.nanoTime();
long remaining = deadline - clock.getAsLong();
if (remaining <= 0L) {
cancelBackgroundTask(task, 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.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
@@ -115,7 +116,8 @@ public class EngineBackgroundTasksAdmissionTest {
@Test
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 firstRelease = new CountDownLatch(1);
CountDownLatch saveEntered = new CountDownLatch(1);
@@ -132,9 +134,9 @@ public class EngineBackgroundTasksAdmissionTest {
fixture.tasks().scheduleAdmittedTask(fixture.engine(),
() -> waitForRelease(saveEntered, saveRelease))));
assertTrue(saveEntered.await(5, TimeUnit.SECONDS));
TimeUnit.SECONDS.sleep(7);
clock.set(TimeUnit.SECONDS.toNanos(15) - TimeUnit.MILLISECONDS.toNanos(1));
firstRelease.countDown();
EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(10, TimeUnit.SECONDS);
EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(5, TimeUnit.SECONDS);
assertTrue(result.failure() instanceof TimeoutException);
assertThrows(IllegalStateException.class, () -> result.requireComplete("Studio cutover"));
} finally {
@@ -287,7 +289,10 @@ public class EngineBackgroundTasksAdmissionTest {
}
private static Fixture fixture() {
EngineBackgroundTasks tasks = new EngineBackgroundTasks();
return fixture(new EngineBackgroundTasks());
}
private static Fixture fixture(EngineBackgroundTasks tasks) {
tasks.openBackgroundTaskAdmission();
GenerationSessionManager sessions = new GenerationSessionManager(true);
Engine engine = mock(Engine.class);
+1
View File
@@ -17,6 +17,7 @@
#
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.workers.max=32
org.gradle.jvmargs=-Xmx3072m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.caching=true
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.')
}
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) {
group = 'verification'
description = 'Checks the actual bundled generation library contents against the current build revision.'
dependsOn(buildRevisionVerification)
classpath = files('../../buildSrc/build/libs/buildSrc.jar')
classpath = files("../../buildSrc/build/${buildLogicVariant}libs/buildSrc.jar")
mainClass.set('art.arcane.iris.buildtools.GenerationBuildRevision')
doFirst {
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<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 {
options.release.set((int) irisModTarget.java)
}
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 {
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 variant = adapter ? "minecraft${minecraftVersion}-java${runtimeJava}" : "java${runtimeJava}"
File projectCacheRoot = gradle.startParameter.projectCacheDir ?: new File(settingsDir, '.gradle')
gradle.startParameter.projectCacheDir = new File(projectCacheRoot, variant)
if (!gradle.extensions.extraProperties.has('irisBuildLane')) {
File projectCacheRoot = gradle.startParameter.projectCacheDir ?: new File(settingsDir, '.gradle')
gradle.startParameter.projectCacheDir = new File(projectCacheRoot, variant)
}
String runtimeOutputVariant = runtimeJava == '21' ? 'java21/' : ''
if (adapter) {