Modded adapters

This commit is contained in:
Brian Neumann-Fopiano
2026-08-15 15:58:26 -04:00
parent 8297e49ed2
commit ef2e1b8f58
29 changed files with 2647 additions and 710 deletions
@@ -10,6 +10,7 @@ import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.core.nms.MinecraftVersion;
import art.arcane.iris.core.nms.ServerShutdownBoundary;
import art.arcane.iris.core.nms.container.BiomeColor;
import art.arcane.iris.core.nms.container.Pair;
import art.arcane.iris.core.nms.container.BlockProperty;
@@ -1714,6 +1715,17 @@ public class NMSBinding implements INMSBinding {
return PaperLevelOverrides.createFromLiveLevelData(primaryLevelData);
}
@Override
public boolean awaitServerShutdownBoundary(long timeout, TimeUnit unit) {
MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer();
return ServerShutdownBoundary.await(
() -> server.hasFullyShutdown,
server.getRunningThread(),
timeout,
unit
);
}
@Override
public KMap<Material, List<BlockProperty>> getBlockProperties() {
KMap<Material, List<BlockProperty>> states = new KMap<>();
@@ -0,0 +1,28 @@
package art.arcane.iris.core.nms.v26_2_R1;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
public class NMSBindingShutdownBoundaryContractTest {
@Test
public void shutdownBoundaryUsesPaperFullyShutdownStateAndServerThread() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource")));
String boundary = section(source, "public boolean awaitServerShutdownBoundary", "public KMap<Material, List<BlockProperty>> getBlockProperties");
assertTrue(boundary.contains("ServerShutdownBoundary.await("));
assertTrue(boundary.contains("server.hasFullyShutdown"));
assertTrue(boundary.contains("server.getRunningThread()"));
}
private static String section(String source, String startMarker, String endMarker) {
int start = source.indexOf(startMarker);
int end = source.indexOf(endMarker, start);
assertTrue("Missing source section starting with " + startMarker, start >= 0);
assertTrue("Missing source section ending with " + endMarker, end > start);
return source.substring(start, end);
}
}
+2
View File
@@ -67,6 +67,8 @@ tasks.named('test').configure {
systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath)
systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath)
systemProperty('iris.engineSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java').absolutePath)
systemProperty('iris.jigsawStudioSvcSource', rootProject.file('core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java').absolutePath)
systemProperty('iris.studioSvcSource', rootProject.file('core/src/main/java/art/arcane/iris/core/service/StudioSVC.java').absolutePath)
systemProperty('iris.terrainSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java').absolutePath)
systemProperty('iris.apiEventSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java').absolutePath)
systemProperty('iris.worldInfoFactorySource', file('src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java').absolutePath)
@@ -75,7 +75,6 @@ import art.arcane.iris.core.service.WandSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.EnginePanic;
import art.arcane.iris.engine.framework.BlockEditAccess;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.PreservationRegistry;
import art.arcane.iris.engine.framework.TreeBlockMaterial;
import art.arcane.iris.engine.object.IrisCompat;
@@ -148,6 +147,8 @@ import java.util.regex.Pattern;
@SuppressWarnings("CanBeFinal")
public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private static final long SERVER_SHUTDOWN_BOUNDARY_TIMEOUT_SECONDS = 300L;
private static final long SERVER_STOP_PREGEN_TIMEOUT_MILLIS = 30000L;
private static final Queue<Runnable> syncJobs = new ShurikenQueue<>();
static {
@@ -175,8 +176,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private static final Object TEARDOWN_LOCK = new Object();
private final AtomicBoolean alreadyDrained = new AtomicBoolean(false);
private final AtomicBoolean postStopFinisherStarted = new AtomicBoolean(false);
private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false);
private final AtomicBoolean servicesDisabled = new AtomicBoolean(false);
private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false);
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
private volatile PlaceholderRegistration papiRegistration;
private volatile IrisPapiListener papiListener;
private volatile IrisPapiState papiState;
@@ -184,11 +188,13 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
// Copy-on-write: mutated on the main thread during enable() and iterated by the JVM
// shutdown-hook thread during teardown; a plain list would CME and abort the teardown.
private final List<IrisService> enabledServices = new CopyOnWriteArrayList<>();
private final List<PlatformChunkGenerator> deferredShutdownGenerators = new CopyOnWriteArrayList<>();
private final IrisWorldGeneratorResolver generatorResolver = new IrisWorldGeneratorResolver(this);
private final BukkitWorldReconciler worldReconciler = new BukkitWorldReconciler(this);
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile Thread serverLifecycleThread;
public static VolmitSender getSender() {
if (sender == null) {
@@ -530,8 +536,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return false;
}
alreadyDrained.set(false);
postStopFinisherStarted.set(false);
serverStopTeardownDeferred.set(false);
servicesDisabled.set(false);
sharedRuntimeClosed.set(false);
terminalCleanupCompleted.set(false);
deferredShutdownGenerators.clear();
MultiBurst.burst.reopen();
MultiBurst.ioBurst.reopen();
IrisLanguage.initialize();
@@ -669,7 +679,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void addShutdownHook() {
removeShutdownHook();
shutdownHook = new Thread(() -> teardownRuntime("shutdown-hook", 30L), "Iris-ShutdownHook");
serverLifecycleThread = Thread.currentThread();
shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook");
try {
Runtime.getRuntime().addShutdownHook(shutdownHook);
} catch (IllegalStateException ex) {
@@ -756,9 +767,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void onDisable() {
teardownPapi();
teardownRuntime("onDisable", 30L);
removeShutdownHook();
J.attempt(() -> INMS.get().uninjectBukkit());
boolean serverStopping = IrisToolbelt.isServerStopping();
if (serverStopping) {
quiesceRuntimeForServerShutdown("onDisable");
startPostStopFinisher();
} else {
teardownRuntime("onDisable", 30L);
removeShutdownHook();
}
if (BukkitPlatform.hasHud()) {
BukkitPlatform.hudSlots().shutdown();
BukkitPlatform.hudLanes().shutdown();
@@ -767,15 +783,22 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
configHotloadEngine.clear();
configHotloadEngine = null;
}
runPostShutdown();
// super.onDisable() cancels plugin tasks and unregisters every listener.
super.onDisable();
IrisPlatforms.unbind();
if (!serverStopping) {
finishTerminalCleanup();
}
}
@Override
public void onPreUnload(ReloadAware.PreUnloadReason reason) {
teardownPapi();
if (IrisToolbelt.isServerStopping()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher();
Iris.info("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
return;
}
if (alreadyDrained.get()) {
Iris.info("Pre-unload hook skipped; Iris already drained.");
return;
@@ -829,14 +852,131 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
}
private void drainWorldGenerators(String reason, long timeoutSeconds) {
List<World> irisWorlds = new ArrayList<>();
for (World world : Bukkit.getWorlds()) {
if (IrisToolbelt.access(world) != null) {
irisWorlds.add(world);
private void quiesceRuntimeForServerShutdown(String reason) {
serverStopTeardownDeferred.set(true);
JigsawStudioService jigsawStudioService = IrisServices.getOrNull(JigsawStudioService.class);
if (jigsawStudioService != null) {
try {
jigsawStudioService.quiesceForServerShutdown();
} catch (Throwable e) {
Iris.reportError("Failed to quiesce Jigsaw Studio before server shutdown.", e);
}
}
if (irisWorlds.isEmpty()) {
StudioSVC studioService = IrisServices.getOrNull(StudioSVC.class);
if (studioService != null) {
studioService.quiesceDownloadsForShutdown();
}
try {
PregeneratorJob.shutdownAndWait(SERVER_STOP_PREGEN_TIMEOUT_MILLIS);
} catch (Throwable e) {
Iris.reportError("Failed to quiesce the Iris pregenerator before server shutdown.", e);
}
for (World world : Bukkit.getWorlds()) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null) {
continue;
}
IrisToolbelt.beginWorldMaintenance(world, reason, true);
if (!deferredShutdownGenerators.contains(generator)) {
deferredShutdownGenerators.add(generator);
}
generator.quiesceForServerShutdown();
}
}
private void startPostStopFinisher() {
if (!postStopFinisherStarted.compareAndSet(false, true)) {
return;
}
Thread activeServerThread = serverLifecycleThread;
if (activeServerThread == null) {
Iris.warn("Iris could not start its post-stop runtime finisher because the server lifecycle thread is unavailable.");
return;
}
Thread finisher = new Thread(() -> {
if (!awaitServerThreadTermination(activeServerThread)) {
return;
}
finishDeferredRuntimeTeardown("post-server-stop", 30L);
}, "Iris-PostStop-Finisher");
finisher.setDaemon(false);
finisher.start();
}
static boolean awaitServerThreadTermination(Thread serverThread) {
if (serverThread == null || serverThread == Thread.currentThread()) {
return false;
}
try {
serverThread.join();
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
Iris.reportError("Iris post-stop runtime finisher was interrupted.", e);
return false;
}
}
private void runShutdownHook() {
if (!awaitServerShutdownBoundary()) {
Iris.warn("Iris skipped JVM-hook runtime teardown because Paper did not reach its post-world-close boundary.");
return;
}
finishDeferredRuntimeTeardown("shutdown-hook", 30L);
}
private boolean awaitServerShutdownBoundary() {
if (!INMS.isBound()) {
return true;
}
try {
return INMS.get().awaitServerShutdownBoundary(
SERVER_SHUTDOWN_BOUNDARY_TIMEOUT_SECONDS,
TimeUnit.SECONDS
);
} catch (Throwable e) {
Iris.reportError("Failed to await Paper's post-world-close shutdown boundary.", e);
return false;
}
}
private void finishDeferredRuntimeTeardown(String reason, long timeoutSeconds) {
teardownRuntime(reason, timeoutSeconds);
finishTerminalCleanup();
}
private void finishTerminalCleanup() {
if (!terminalCleanupCompleted.compareAndSet(false, true)) {
return;
}
J.attempt(() -> INMS.get().uninjectBukkit());
try {
runPostShutdown();
} catch (Throwable e) {
Iris.reportError("Failed to run Iris post-shutdown cleanup.", e);
} finally {
IrisPlatforms.unbind();
}
}
private void drainWorldGenerators(String reason, long timeoutSeconds) {
List<World> irisWorlds = new ArrayList<>();
List<PlatformChunkGenerator> generators = new ArrayList<>();
if (serverStopTeardownDeferred.get()) {
generators.addAll(deferredShutdownGenerators);
} else {
for (World world : Bukkit.getWorlds()) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator != null) {
irisWorlds.add(world);
generators.add(generator);
}
}
}
if (generators.isEmpty()) {
Iris.info("No Iris worlds to freeze.");
return;
}
@@ -848,17 +988,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
J.attempt(PregeneratorJob::shutdownInstance);
List<CompletableFuture<Void>> closes = new ArrayList<>();
for (World world : irisWorlds) {
PlatformChunkGenerator gen = IrisToolbelt.access(world);
if (gen == null) continue;
Engine engine = gen.getEngine();
if (engine != null) {
J.attempt(() -> engine.getMantle().saveAllNow());
}
for (PlatformChunkGenerator generator : generators) {
try {
closes.add(gen.closeAsync());
closes.add(generator.closeAsync());
} catch (Throwable t) {
Iris.reportError(t);
}
@@ -45,6 +45,7 @@ import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
@@ -53,6 +54,7 @@ import java.util.function.Supplier;
* Bukkit plugin entry points delegate to.
*/
public final class IrisWorldGeneratorResolver {
private static final int VALIDATION_STABILITY_ATTEMPTS = 2;
private static final Object SNAPSHOT_VALIDATION_LOCK = new Object();
private final VolmitPlugin plugin;
@@ -67,15 +69,16 @@ public final class IrisWorldGeneratorResolver {
PackValidationRegistry.clear();
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
String contentFingerprint = "";
ServerConfigurator.PackContentSnapshot contentSnapshot =
new ServerConfigurator.PackContentSnapshot("", Map.of());
String contextFingerprint = "";
Optional<List<PackValidationResult>> cached = Optional.empty();
try {
contentFingerprint = PackValidationCache.contentFingerprint(packsRoot);
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint();
cached = PackValidationCache.load(
cacheFile,
contentFingerprint,
contentSnapshot.content(),
contextFingerprint,
packNames);
} catch (RuntimeException exception) {
@@ -88,33 +91,31 @@ public final class IrisWorldGeneratorResolver {
Iris.info("Reused persisted validation for " + results.size()
+ " unchanged Iris pack(s); full pack parsing was skipped.");
} else {
results = new ArrayList<>(packDirs.size());
for (File packDir : packDirs) {
FreshValidation validation = validateStablePacks(packsRoot, packDirs, contentSnapshot);
packDirs = validation.packDirs();
contentSnapshot = validation.contentSnapshot();
results = validation.results();
if (validation.stable()) {
try {
results.add(PackValidator.validate(packDir));
} catch (Throwable exception) {
Iris.reportError("Pack validation failed for '" + packDir.getName() + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
}
results.add(new PackValidationResult(
packDir.getName(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis()));
PackValidationCache.save(
cacheFile,
contentSnapshot.content(),
contextFingerprint,
results);
} catch (IOException exception) {
Iris.reportError("Could not persist Iris pack-validation results", exception);
}
}
try {
PackValidationCache.save(cacheFile, contentFingerprint, contextFingerprint, results);
} catch (IOException exception) {
Iris.reportError("Could not persist Iris pack-validation results", exception);
}
}
Map<String, String> packFingerprints = contentSnapshot.packContents();
for (PackValidationResult result : results) {
PackValidationRegistry.publish(result);
String packFingerprint = packFingerprints.get(result.getPackName());
File packDirectory = PackDirectoryResolver.resolveExisting(packsRoot, result.getPackName());
if (packDirectory != null && packFingerprint != null && !packFingerprint.isBlank()) {
PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint);
}
if (!result.isLoadable()) {
Iris.error("Pack '" + result.getPackName()
+ "' FAILED validation - world and Studio creation with this pack will be refused. Reasons:");
@@ -134,6 +135,67 @@ public final class IrisWorldGeneratorResolver {
IrisStartupValidation.markPacksReady();
}
private static FreshValidation validateStablePacks(
File packsRoot,
List<File> initialPackDirs,
ServerConfigurator.PackContentSnapshot initialSnapshot
) {
List<File> packDirs = initialPackDirs;
ServerConfigurator.PackContentSnapshot before = initialSnapshot;
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
List<PackValidationResult> results = validatePacks(packDirs);
ServerConfigurator.PackContentSnapshot after;
try {
after = ServerConfigurator.computePackContentSnapshot(packsRoot);
} catch (RuntimeException exception) {
Iris.reportError("Could not verify Iris pack bytes after validation", exception);
after = new ServerConfigurator.PackContentSnapshot("", Map.of());
}
List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
if (!before.content().isBlank()
&& before.content().equals(after.content())
&& packNames.equals(afterPackNames)) {
return new FreshValidation(afterPackDirs, after, results, true);
}
packDirs = afterPackDirs;
before = after;
}
Iris.error("Iris pack files kept changing during validation; validation was refused until writes stop.");
List<PackValidationResult> failures = new ArrayList<>(packDirs.size());
for (File packDir : packDirs) {
failures.add(new PackValidationResult(
packDir.getName(),
List.of("Pack files changed while validation was in progress; retry after writes stop."),
List.of(),
System.currentTimeMillis()));
}
return new FreshValidation(packDirs, before, failures, false);
}
private static List<PackValidationResult> validatePacks(List<File> packDirs) {
List<PackValidationResult> results = new ArrayList<>(packDirs.size());
for (File packDir : packDirs) {
try {
results.add(PackValidator.validate(packDir));
} catch (Throwable exception) {
Iris.reportError("Pack validation failed for '" + packDir.getName() + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
}
results.add(new PackValidationResult(
packDir.getName(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis()));
}
}
return results;
}
static PackValidationResult requireSnapshotLoadable(File packRoot) {
Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize();
PackValidationResult result = PackValidationRegistry.get(normalizedRoot);
@@ -141,22 +203,26 @@ public final class IrisWorldGeneratorResolver {
synchronized (SNAPSHOT_VALIDATION_LOCK) {
result = PackValidationRegistry.get(normalizedRoot);
if (result == null) {
try {
result = PackValidator.validate(normalizedRoot.toFile());
} catch (Throwable exception) {
Iris.reportError("Snapshot pack validation failed for '" + normalizedRoot + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
PackValidationRegistry.ValidationTicket ticket =
PackValidationRegistry.tryBeginValidation(normalizedRoot);
if (ticket != null) {
try {
result = PackValidator.validate(normalizedRoot.toFile());
} catch (Throwable exception) {
Iris.reportError("Snapshot pack validation failed for '" + normalizedRoot + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
}
result = new PackValidationResult(
normalizedRoot.getFileName().toString(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis());
}
result = new PackValidationResult(
normalizedRoot.getFileName().toString(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis());
PackValidationRegistry.publishIfCurrent(ticket, result);
}
PackValidationRegistry.publish(normalizedRoot, result);
}
}
}
@@ -265,4 +331,12 @@ public final class IrisWorldGeneratorResolver {
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
}
private record FreshValidation(
List<File> packDirs,
ServerConfigurator.PackContentSnapshot contentSnapshot,
List<PackValidationResult> results,
boolean stable
) {
}
}
@@ -0,0 +1,166 @@
package art.arcane.iris;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisShutdownOrderingTest {
@Test
public void drainWorldGenerators_closesGeneratorsWithoutEagerMantleSave() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String drain = section(source, "private void drainWorldGenerators", "private void setupPapi");
assertTrue("Shutdown must close each Iris generator", drain.contains("generator.closeAsync()"));
assertFalse("Shutdown must not close Mantle plates before generation drains", drain.contains("saveAllNow()"));
}
@Test
public void serverStop_defersRuntimeTeardownUntilPaperShutdownBoundary() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String onDisable = section(source, "public void onDisable()", "public void onPreUnload");
String quiesce = section(source, "private void quiesceRuntimeForServerShutdown", "private void startPostStopFinisher");
String shutdownHook = section(source, "private void runShutdownHook()", "private boolean awaitServerShutdownBoundary");
String finisher = section(source, "private void startPostStopFinisher()", "static boolean awaitServerThreadTermination");
String serverJoin = section(source, "static boolean awaitServerThreadTermination", "private void runShutdownHook");
String generatorSource = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource")));
String generatorQuiesce = section(generatorSource,
"public void quiesceForServerShutdown()", "public boolean isStudio()");
assertOrdered(onDisable,
"if (serverStopping)",
"quiesceRuntimeForServerShutdown(\"onDisable\")",
"startPostStopFinisher()",
"else",
"teardownRuntime(\"onDisable\", 30L)");
assertOrdered(shutdownHook,
"awaitServerShutdownBoundary()",
"finishDeferredRuntimeTeardown(\"shutdown-hook\", 30L)");
assertOrdered(finisher,
"finisher.setDaemon(false)",
"finisher.start()");
assertOrdered(finisher,
"awaitServerThreadTermination(activeServerThread)",
"finishDeferredRuntimeTeardown(\"post-server-stop\", 30L)");
assertOrdered(serverJoin,
"serverThread == Thread.currentThread()",
"serverThread.join()");
assertTrue("Server-stop quiescence must leave queued Paper generation admitted",
quiesce.contains("generator.quiesceForServerShutdown()"));
assertOrdered(quiesce,
"jigsawStudioService.quiesceForServerShutdown()",
"PregeneratorJob.shutdownAndWait",
"generator.quiesceForServerShutdown()");
assertOrdered(onDisable,
"quiesceRuntimeForServerShutdown(\"onDisable\")",
"super.onDisable()");
assertFalse("Server-stop quiescence must not begin generator close before Paper's boundary",
generatorQuiesce.contains("closing = true"));
assertFalse("Server-stop quiescence must not dispatch generator close before Paper's boundary",
generatorQuiesce.contains("closeAsync()"));
}
@Test
public void deferredTeardown_keepsGeneratorClosingServicesBehindGeneratorDrain() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String engineService = Files.readString(Path.of(System.getProperty("iris.engineSvcSource")));
String studioService = Files.readString(Path.of(System.getProperty("iris.studioSvcSource")));
String generatorSource = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource")));
String teardown = section(source, "private void teardownRuntime", "private void quiesceRuntimeForServerShutdown");
String engineDisable = section(engineService, "public void onDisable()", "public void engineStatus");
String studioDisable = section(studioService, "public void onDisable()", "public IrisDimension installIntoWorld");
String generatorClose = section(generatorSource,
"public void close()", "public CompletableFuture<Void> closeAsync()");
String generatorCloseAsync = section(generatorSource,
"public CompletableFuture<Void> closeAsync()", "public void quiesceForServerShutdown()");
assertOrdered(teardown,
"drainWorldGenerators(reason, timeoutSeconds)",
"service.onDisable()",
"MultiBurst.burst::close",
"MultiBurst.ioBurst::close");
assertTrue("Engine service must retain its generator close behind deferred service teardown",
engineDisable.contains("startClose("));
assertTrue("Studio service must retain its generator close behind deferred service teardown",
studioDisable.contains("generator.close()"));
assertTrue("Service-level generator close must delegate to the shared idempotent close future",
generatorClose.contains("closeAsync()"));
assertTrue("Repeated post-boundary closes must return the already-published close future",
generatorCloseAsync.contains("return existing;"));
assertFalse("A completed generator close must never be re-dispatched",
generatorCloseAsync.contains("!existing.isDone()"));
}
@Test
public void preUnload_doesNotDrainGeneratorsDuringServerStop() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String preUnload = section(source, "public void onPreUnload", "private void drainOnce");
assertOrdered(preUnload,
"IrisToolbelt.isServerStopping()",
"quiesceRuntimeForServerShutdown",
"startPostStopFinisher()",
"return;",
"drainOnce(");
}
@Test
public void jigsawStudio_quiescesOnceBeforeDeferredServiceTeardown() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.jigsawStudioSvcSource")));
String enable = section(source, "public void onEnable()", "public void onDisable()");
String disable = section(source, "public void onDisable()", "public void quiesceForServerShutdown()");
String quiesce = section(source, "public void quiesceForServerShutdown()", "public void register(");
String register = section(source, "public void register(", "public void activationCommitted(");
String activation = section(source, "public void activationCommitted(", "public void markChunkGenerated(");
String chunkGenerated = section(source, "public void markChunkGenerated(", "private void markChunkAvailable(");
assertOrdered(enable, "disableStarted.set(false)", "enabled = true");
assertTrue("Deferred service teardown must reuse the idempotent early disable",
disable.contains("quiesceForServerShutdown();"));
assertOrdered(quiesce,
"disableStarted.compareAndSet(false, true)",
"finalizeAllJigsawTileWatches()",
"drainAutosavesBeforeDisable()",
"enabled = false",
"activeMenuController.closeAll()",
"previewRenderer.removeAll()",
"studios.clear()");
assertTrue("Registration must reject work after shutdown begins",
occurrences(register, "!enabled || disableStarted.get()") >= 2);
assertTrue("Activation must reject work after shutdown begins",
activation.contains("!enabled || disableStarted.get()"));
assertTrue("Chunk-generation callbacks must reject work after shutdown begins",
occurrences(chunkGenerated, "!enabled || disableStarted.get()") >= 2);
}
private static String section(String source, String startMarker, String endMarker) {
int start = source.indexOf(startMarker);
int end = source.indexOf(endMarker, start);
assertTrue("Missing source section starting with " + startMarker, start >= 0);
assertTrue("Missing source section ending with " + endMarker, end > start);
return source.substring(start, end);
}
private static void assertOrdered(String source, String... markers) {
int previous = -1;
for (String marker : markers) {
int current = source.indexOf(marker);
assertTrue("Missing source marker " + marker, current >= 0);
assertTrue("Source marker is out of order: " + marker, current > previous);
previous = current;
}
}
private static int occurrences(String source, String marker) {
int count = 0;
int offset = 0;
while ((offset = source.indexOf(marker, offset)) >= 0) {
count++;
offset += marker.length();
}
return count;
}
}
@@ -64,6 +64,23 @@ public class IrisWorldGeneratorResolverTest {
assertFalse(invalid.getBlockingErrors().toString(), invalid.isLoadable());
}
@Test
public void startupValidationPublishesFingerprintBoundExactRootResults() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java"));
int validateAll = source.indexOf("public void validateAllPacks()");
int snapshot = source.indexOf("ServerConfigurator.computePackContentSnapshot(packsRoot)", validateAll);
int perPackFingerprint = source.indexOf("contentSnapshot.packContents()", snapshot);
int exactRootPublish = source.indexOf(
"PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint)",
perPackFingerprint);
assertTrue(validateAll >= 0);
assertTrue(snapshot > validateAll);
assertTrue(perPackFingerprint > snapshot);
assertTrue(exactRootPublish > perPackFingerprint);
}
@Test
public void paperStartupAliasResolvesToCanonicalRuntimeKey() {
assertEquals(
@@ -17,6 +17,7 @@ public class BukkitEngineLifecycleContractTest {
assertBefore(closeAsync, "closeFuture.compareAndSet(null, future)", "withExclusiveControlFuture(");
assertTrue(closeAsync.contains("while (!closeFuture.compareAndSet(null, future))"));
assertTrue(closeAsync.contains("return existing;"));
assertTrue(closeAsync.contains("operation.whenComplete("));
assertFalse(closeAsync.contains("!existing.isDone()"));
+3 -488
View File
@@ -1,488 +1,3 @@
[23:16:06] [Test worker/WARN]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[23:16:06] [Test worker/WARN]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[23:16:06] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:definitely_not_a_real_block
[23:16:06] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:minecraft:definitely_not_a_real_block
[23:16:06] [Test worker/INFO]: [STDERR]: [Iris/WARN] Block 'minecraft:oak_log' rejected state 'not_a_property=x'; using its default state
[23:16:06] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test'
[23:16:06] [Test worker/WARN]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial3600839487069132870/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial3600839487069132870/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[23:16:06] [Test worker/ERROR]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot13638847434645426607/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot13638847434645426607/iris-dimensions.json could not be read; refusing to discard persistent worlds
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150)
at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56)
at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69)
at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.startupLoadQuarantinesACorruptRegistryInsteadOfFailingBoot(ModdedDimensionRegistryStoreTest.java:99)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must end with '}' at 34 [character 35 line 1]
at art.arcane.volmlib.util.json.JSONTokener.syntaxError(JSONTokener.java:414)
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:145)
at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:345)
at art.arcane.volmlib.util.json.JSONArray.<init>(JSONArray.java:111)
at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:348)
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:159)
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:260)
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117)
... 45 more
[23:16:06] [Test worker/ERROR]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[23:16:06] [Test worker/ERROR]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot13638847434645426607/iris-dimensions.json.broken-1786418166450
[23:16:06] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[23:16:06] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[23:16:06] [Test worker/ERROR]: Iris disabled all services with 2 failure(s)
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Suppressed: java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54)
... 42 more
[23:16:06] [Test worker/ERROR]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[23:16:06] [Test worker/ERROR]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: enable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Suppressed: java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32)
... 42 more
[23:16:06] [Test worker/ERROR]: [worldcheck] server stop request failed
java.lang.IllegalStateException: stop request failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238)
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:144)
at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:235)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[23:16:06] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed
java.lang.IllegalStateException: shutdown wait failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264)
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:159)
at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:262)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[23:16:06] [Test worker/ERROR]: [worldcheck] check failed
java.lang.IllegalStateException: check failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224)
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:139)
at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:222)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[23:16:06] [Test worker/INFO]: Iris registered custom content provider 'iris_discovery_success'
[23:16:06] [Test worker/WARN]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
java.lang.RuntimeException: provider init failed
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[23:16:06] [Test worker/INFO]: Iris object undo service ready (bounded to 32 paste(s) per player)
[23:16:06] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered
[23:16:06] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered
[15:24:46] [Test worker/INFO]: Iris object undo service ready (bounded to 32 paste(s) per player)
[15:24:46] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered
[15:24:46] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered
@@ -40,6 +40,7 @@ import java.util.stream.Stream;
public final class IrisDatapackCompiler {
private static final int INPUT_FINGERPRINT_SCHEMA = 2;
private static final int INPUT_BUFFER_BYTES = 64 * 1024;
private static final int WORLD_PACK_SCAN_DEPTH = 8;
private static final List<String> INPUT_DIRECTORIES = List.of("dimensions", "biomes", "snippet");
private static final String FLAT_VOID_LEVEL_STEM = """
@@ -138,18 +139,26 @@ public final class IrisDatapackCompiler {
List<CompilerInputEntry> entries = collectCompilerInputEntries(normalizedRoot);
updateDigestInt(digest, entries.size());
byte[] buffer = new byte[8192];
byte[] buffer = new byte[INPUT_BUFFER_BYTES];
for (CompilerInputEntry entry : entries) {
updateDigestString(digest, entry.relativePath());
updateDigestLong(digest, Files.size(entry.source()));
try (InputStream input = Files.newInputStream(entry.source())) {
updateDigestLong(digest, entry.size());
long readBytes = 0L;
try (InputStream input = Files.newInputStream(
entry.source(),
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS)) {
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
readBytes += read;
}
}
}
if (readBytes != entry.size()) {
throw new IOException("Iris datapack compiler input changed while hashing: " + entry.source());
}
}
}
return HexFormat.of().formatHex(digest.digest());
@@ -326,26 +335,53 @@ public final class IrisDatapackCompiler {
|| !Files.isDirectory(dimensionsRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
List<Path> namespaces = visibleDirectories(dimensionsRoot);
List<Path> candidates = new ArrayList<>();
Files.walkFileTree(dimensionsRoot, Set.of(), WORLD_PACK_SCAN_DEPTH, new SimpleFileVisitor<>() {
for (Path namespace : namespaces) {
collectNamespaceWorldPackRoots(namespace, candidates);
}
candidates.sort(Comparator.comparing(Path::toString));
for (Path candidate : candidates) {
addPackRoot(candidate, roots, validateWholePack);
}
}
private static void collectNamespaceWorldPackRoots(
Path namespace,
List<Path> candidates
) throws IOException {
Files.walkFileTree(namespace, Set.of(), WORLD_PACK_SCAN_DEPTH, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
if (!directory.equals(dimensionsRoot)
&& PackDirectoryResolver.containsHiddenPathSegment(dimensionsRoot, directory)) {
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) {
if (!directory.equals(namespace)
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
return FileVisitResult.SKIP_SUBTREE;
}
if ("pack".equals(directory.getFileName().toString())
&& directory.getParent() != null
&& "iris".equals(directory.getParent().getFileName().toString())
&& hasDimensions(directory)) {
candidates.add(directory);
Path irisRoot = directory.resolve("iris");
Path candidate = irisRoot.resolve("pack");
if (!Files.isSymbolicLink(irisRoot)
&& !Files.isSymbolicLink(candidate)
&& Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)
&& hasDimensions(candidate)) {
candidates.add(candidate);
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
});
candidates.sort(Comparator.comparing(Path::toString));
for (Path candidate : candidates) {
addPackRoot(candidate, roots, validateWholePack);
}
private static List<Path> visibleDirectories(Path root) throws IOException {
try (Stream<Path> entries = Files.list(root)) {
return entries
.filter(entry -> !PackDirectoryResolver.isHiddenName(entry.getFileName().toString()))
.filter(entry -> !Files.isSymbolicLink(entry))
.filter(entry -> Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS))
.sorted(Comparator.comparing(Path::toString))
.toList();
}
}
@@ -416,7 +452,7 @@ public final class IrisDatapackCompiler {
}
if (file.getFileName().toString().endsWith(".json")) {
String relativePath = packRoot.relativize(file).toString().replace(File.separatorChar, '/');
entries.add(new CompilerInputEntry(file, relativePath));
entries.add(new CompilerInputEntry(file, relativePath, attributes.size()));
}
return FileVisitResult.CONTINUE;
}
@@ -503,7 +539,7 @@ public final class IrisDatapackCompiler {
public record CompilationResult(int packCount, int dimensionCount, int biomeCount) {
}
private record CompilerInputEntry(Path source, String relativePath) {
private record CompilerInputEntry(Path source, String relativePath, long size) {
}
private record DimensionCandidate(
@@ -72,7 +72,9 @@ import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@@ -86,6 +88,7 @@ public class ServerConfigurator {
private static final Object DATAPACK_INSTALL_LOCK = new Object();
private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace";
private static final String COMPILER_INPUT_FINGERPRINT_CACHE = "datapack-compiler-input-fingerprint";
private static final int FINGERPRINT_BUFFER_BYTES = 64 * 1024;
private static volatile boolean loadedDatapackRuntimeReady;
private static volatile String loadedDatapackCompilerInputFingerprint = "";
private static volatile long loadedDatapackRuntimeGeneration;
@@ -401,7 +404,10 @@ public class ServerConfigurator {
String current;
long fingerprintStart = System.nanoTime();
try {
current = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
current = restoredCompilerInputFingerprint();
if (current.isBlank()) {
current = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
}
} catch (IOException | RuntimeException exception) {
reportTiming(timingConsumer, "datapack_compiler_input_fingerprint", fingerprintStart);
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
@@ -473,6 +479,13 @@ public class ServerConfigurator {
IrisSettings.get().getGeneral().adjustVanillaHeight);
}
static String restoredCompilerInputFingerprint() {
if (!loadedDatapackRuntimeReady || loadedDatapackRestartRequired) {
return "";
}
return Objects.requireNonNullElse(loadedDatapackCompilerInputFingerprint, "");
}
private static boolean pinLoadedDatapackCompilerInputs() {
return pinLoadedDatapackCompilerInputs(null);
}
@@ -570,13 +583,11 @@ public class ServerConfigurator {
List<FingerprintEntry> entries = collectFingerprintEntries(root.toRealPath());
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
for (FingerprintEntry entry : entries) {
BasicFileAttributes attributes = Files.readAttributes(
entry.source(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, relativePath.length);
digest.update(relativePath);
updateDigestLong(digest, attributes.size());
updateDigestLong(digest, attributes.lastModifiedTime().toMillis());
updateDigestLong(digest, entry.size());
updateDigestLong(digest, entry.lastModifiedMillis());
}
return HexFormat.of().formatHex(digest.digest());
} catch (IOException exception) {
@@ -587,31 +598,56 @@ public class ServerConfigurator {
}
public static String computePackFingerprint(File packsDir) {
return computePackContentSnapshot(packsDir).content();
}
public static PackContentSnapshot computePackContentSnapshot(File packsDir) {
Path root = resolveFingerprintRoot(packsDir);
if (root == null) {
return "";
return new PackContentSnapshot("", Map.of());
}
try {
Path resolvedRoot = root.toRealPath();
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Map<String, MessageDigest> packDigests = new LinkedHashMap<>();
List<FingerprintEntry> entries = collectFingerprintEntries(resolvedRoot);
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
byte[] buffer = new byte[8192];
byte[] buffer = new byte[FINGERPRINT_BUFFER_BYTES];
for (FingerprintEntry entry : entries) {
byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, relativePath.length);
digest.update(relativePath);
updateDigestLong(digest, Files.size(entry.source()));
try (InputStream input = Files.newInputStream(entry.source())) {
MessageDigest packDigest = entry.packName() == null
? null
: packDigests.computeIfAbsent(entry.packName(), ignored -> newSha256Digest());
updateFingerprintEntry(digest, entry.relativePath(), entry.size());
if (packDigest != null) {
updateFingerprintEntry(packDigest, entry.packRelativePath(), entry.size());
}
long readBytes = 0L;
try (InputStream input = Files.newInputStream(
entry.source(),
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS)) {
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
if (packDigest != null) {
packDigest.update(buffer, 0, read);
}
readBytes += read;
}
}
}
if (readBytes != entry.size()) {
throw new IOException("Iris pack changed while fingerprinting: " + entry.source());
}
}
return HexFormat.of().formatHex(digest.digest());
Map<String, String> packContents = new LinkedHashMap<>();
for (Map.Entry<String, MessageDigest> entry : packDigests.entrySet()) {
packContents.put(entry.getKey(), HexFormat.of().formatHex(entry.getValue().digest()));
}
return new PackContentSnapshot(
HexFormat.of().formatHex(digest.digest()),
Map.copyOf(packContents));
} catch (IOException exception) {
throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception);
} catch (NoSuchAlgorithmException e) {
@@ -619,6 +655,59 @@ public class ServerConfigurator {
}
}
public static String computePackTreeFingerprint(File packDir) {
Path root = resolveFingerprintRoot(packDir);
if (root == null) {
return "";
}
try {
List<FingerprintEntry> entries = new ArrayList<>();
collectFingerprintTree(root.toRealPath(), "", null, entries);
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[FINGERPRINT_BUFFER_BYTES];
for (FingerprintEntry entry : entries) {
updateFingerprintEntry(digest, entry.relativePath(), entry.size());
long readBytes = 0L;
try (InputStream input = Files.newInputStream(
entry.source(),
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS)) {
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
readBytes += read;
}
}
}
if (readBytes != entry.size()) {
throw new IOException("Iris pack changed while fingerprinting: " + entry.source());
}
}
return HexFormat.of().formatHex(digest.digest());
} catch (IOException exception) {
throw new UncheckedIOException("Unable to fingerprint Iris pack at " + root, exception);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 not available", exception);
}
}
private static MessageDigest newSha256Digest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 not available", exception);
}
}
private static void updateFingerprintEntry(MessageDigest digest, String relativePath, long size) {
byte[] relativeBytes = relativePath.getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, relativeBytes.length);
digest.update(relativeBytes);
updateDigestLong(digest, size);
}
private static Path resolveFingerprintRoot(File packsDir) {
if (packsDir == null) {
return null;
@@ -714,11 +803,19 @@ public class ServerConfigurator {
throw new IOException("Iris pack fingerprint rejected symbolic link: " + child);
}
PackDirectoryResolver.requireSafePackTree(child.toFile());
collectFingerprintTree(child.toRealPath(), childName, entries);
collectFingerprintTree(child.toRealPath(), childName, childName, entries);
} else if (Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
collectFingerprintTree(child, childName, entries);
collectFingerprintTree(child, childName, childName, entries);
} else if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS)) {
entries.add(new FingerprintEntry(child, childName));
BasicFileAttributes attributes = Files.readAttributes(
child, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
entries.add(new FingerprintEntry(
child,
childName,
null,
null,
attributes.size(),
attributes.lastModifiedTime().toMillis()));
} else {
throw new IOException("Iris pack fingerprint rejected unsupported entry: " + child);
}
@@ -730,12 +827,17 @@ public class ServerConfigurator {
private static void collectFingerprintTree(
Path treeRoot,
String logicalRoot,
String packName,
List<FingerprintEntry> entries
) throws IOException {
Files.walkFileTree(treeRoot, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) {
if (!directory.equals(treeRoot)
&& treeRoot.relativize(directory).getNameCount() == 1
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
return FileVisitResult.SKIP_SUBTREE;
}
@@ -745,7 +847,9 @@ public class ServerConfigurator {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
String fileName = file.getFileName().toString();
if (PackDirectoryResolver.isHiddenName(fileName) || isGeneratedPackFile(fileName)) {
if ((treeRoot.relativize(file).getNameCount() == 1
&& PackDirectoryResolver.isHiddenName(fileName))
|| isGeneratedPackFile(fileName)) {
return FileVisitResult.CONTINUE;
}
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
@@ -755,7 +859,14 @@ public class ServerConfigurator {
throw new IOException("Iris pack fingerprint rejected unsupported entry: " + file);
}
String relative = treeRoot.relativize(file).toString().replace(File.separatorChar, '/');
entries.add(new FingerprintEntry(file, logicalRoot + "/" + relative));
String logicalRelative = logicalRoot.isEmpty() ? relative : logicalRoot + "/" + relative;
entries.add(new FingerprintEntry(
file,
logicalRelative,
packName,
packName == null ? null : relative,
attributes.size(),
attributes.lastModifiedTime().toMillis()));
return FileVisitResult.CONTINUE;
}
@@ -770,12 +881,26 @@ public class ServerConfigurator {
return name != null && name.endsWith(CODE_WORKSPACE_SUFFIX);
}
private record FingerprintEntry(Path source, String relativePath) {
private record FingerprintEntry(
Path source,
String relativePath,
String packName,
String packRelativePath,
long size,
long lastModifiedMillis
) {
}
record PackFingerprint(String metadata, String content) {
}
public record PackContentSnapshot(String content, Map<String, String> packContents) {
public PackContentSnapshot {
content = Objects.requireNonNullElse(content, "");
packContents = Map.copyOf(Objects.requireNonNullElse(packContents, Map.of()));
}
}
private record FingerprintCache(String content, String metadata) {
}
@@ -62,11 +62,13 @@ import java.net.URL;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileVisitResult;
import java.nio.file.FileStore;
import java.nio.file.FileSystems;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
@@ -119,6 +121,7 @@ public final class DatapackIngestService {
private static final int MAX_TRANSACTION_COUNT = 1_024;
private static final int MAX_SCRATCH_DELETE_ATTEMPTS = 3;
private static final int WINDOWS_LEGACY_PATH_LIMIT = 247;
private static final int HASH_BUFFER_BYTES = 64 * 1024;
private static final Set<String> RESERVED_IDS = Set.of("iris");
private static final ReentrantLock TRANSACTION_LOCK = new ReentrantLock();
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
@@ -152,20 +155,23 @@ public final class DatapackIngestService {
Path cacheFile = new File(root, STARTUP_VALIDATION_CACHE).toPath();
try {
String localFingerprint = startupValidationFingerprint(root, worldFolders);
StartupValidationCache cached = readStartupValidationCache(cacheFile);
if (startupValidationCacheMatches(
cached,
mcVersion,
irisVersion,
autoIngest,
stripOverrides,
urls,
localFingerprint)) {
activeStartupValidation = cached;
IrisLogging.info("External datapacks match the persisted startup validation; remote resolution and full revalidation were skipped.");
IrisStartupValidation.markDatapacksReady();
return StartupValidationOutcome.READY;
if (startupValidationContextMatches(
cached, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) {
String localFingerprint = startupValidationFingerprint(root, worldFolders);
if (startupValidationCacheMatches(
cached,
mcVersion,
irisVersion,
autoIngest,
stripOverrides,
urls,
localFingerprint)) {
activeStartupValidation = cached;
IrisLogging.info("External datapacks match the persisted startup validation; remote resolution and full revalidation were skipped.");
IrisStartupValidation.markDatapacksReady();
return StartupValidationOutcome.READY;
}
}
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Persisted external datapack validation could not be reused: "
@@ -216,8 +222,8 @@ public final class DatapackIngestService {
public static void runPostStartupTasks() {
refreshWorkspaces();
autoImportDatapackStructures();
refreshStartupValidationAfterMaintenance();
boolean maintenanceChanged = autoImportDatapackStructures();
refreshStartupValidationAfterMaintenance(maintenanceChanged);
}
private static StartupValidationCache cacheStartupValidation(
@@ -247,7 +253,10 @@ public final class DatapackIngestService {
}
}
private static void refreshStartupValidationAfterMaintenance() {
private static void refreshStartupValidationAfterMaintenance(boolean maintenanceChanged) {
if (!maintenanceChanged) {
return;
}
StartupValidationCache validated = activeStartupValidation;
if (validated == null || !IrisStartupValidation.isReady()) {
return;
@@ -623,6 +632,9 @@ public final class DatapackIngestService {
ManifestWrite manifestWrite = null;
boolean manifestDurabilityConfirmed = false;
try {
for (InstallExecution install : installs) {
verifyInstallExecution(install);
}
manifestWrite = prepareManifestWrite(root, manifest);
manifestWrite.publish();
manifestDurabilityConfirmed = true;
@@ -631,8 +643,9 @@ public final class DatapackIngestService {
rollbackInstallExecutions(installs, manifestFailure);
report.failed.add("manifest - " + manifestFailure.getMessage());
report.updated.clear();
report.upToDate.clear();
report.requiresRestart = false;
message(sender, C.RED + "Datapack ingest rolled back because the manifest could not be committed: "
message(sender, C.RED + "Datapack ingest rolled back before the manifest commit: "
+ manifestFailure.getMessage());
IrisLogging.reportError(manifestFailure);
return report;
@@ -912,7 +925,7 @@ public final class DatapackIngestService {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path rootPath = root.toPath().toAbsolutePath().normalize();
List<Path> entries = new ArrayList<>();
List<MetadataEntry> entries = new ArrayList<>();
try (Stream<Path> paths = Files.walk(rootPath)) {
Iterator<Path> iterator = paths.iterator();
int pathCount = 0;
@@ -928,17 +941,18 @@ public final class DatapackIngestService {
if (Files.isSymbolicLink(path)) {
throw new IOException("Datapack contains a symbolic link: " + path);
}
entries.add(path);
String relativePath = rootPath.relativize(path).toString();
entries.add(new MetadataEntry(path, relativePath));
}
}
entries.sort(Comparator.comparing(path -> rootPath.relativize(path).toString()));
for (Path entry : entries) {
String relative = rootPath.relativize(entry).toString().replace(File.separatorChar, '/');
entries.sort(Comparator.comparing(MetadataEntry::relativePath));
for (MetadataEntry entry : entries) {
String relative = entry.relativePath().replace(File.separatorChar, '/');
byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8);
BasicFileAttributes attributes = Files.readAttributes(
entry, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
entry.path(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Datapack contains an unsupported filesystem entry: " + entry);
throw new IOException("Datapack contains an unsupported filesystem entry: " + entry.path());
}
digest.update((byte) (attributes.isDirectory() ? 1 : 2));
updateDigestInt(digest, relativeBytes.length);
@@ -1909,6 +1923,12 @@ public final class DatapackIngestService {
stripOverrides,
root
);
try {
verifyInstallExecution(execution);
} catch (IOException failure) {
rollbackInstallExecutions(List.of(execution), failure);
throw failure;
}
finishInstallExecution(execution);
return execution.result();
}
@@ -1973,12 +1993,14 @@ public final class DatapackIngestService {
boolean changed = false;
List<InstallPlan> publishPlans = new ArrayList<>();
List<InstallPlan> unchangedPlans = new ArrayList<>();
try {
for (InstallPlan plan : plans) {
changed |= plan.contentChanged();
if (plan.publishRequired()) {
publishPlans.add(plan);
} else {
unchangedPlans.add(plan);
cleanupInstallPlan(plan, true);
}
}
@@ -1993,7 +2015,15 @@ public final class DatapackIngestService {
throw cleanupFailure;
}
if (publishPlans.isEmpty()) {
return new InstallExecution(new InstallResult(changed), null);
return new InstallExecution(
new InstallResult(changed),
null,
stagedDir,
entry,
stagedHash,
verifiedStagingInstall == null,
publishPlans,
unchangedPlans);
}
Manifest committedManifest = readCommittedManifest(root);
@@ -2033,10 +2063,44 @@ public final class DatapackIngestService {
}
throw publishFailure;
}
return new InstallExecution(new InstallResult(changed), coordinator);
return new InstallExecution(
new InstallResult(changed),
coordinator,
stagedDir,
entry,
stagedHash,
verifiedStagingInstall == null,
publishPlans,
unchangedPlans);
}
static void verifyInstallExecution(InstallExecution execution) throws IOException {
if (execution.verified()) {
return;
}
if (execution.verifyStagedSource()) {
validateManagedDirectory(execution.stagedDir(), execution.entry().id);
Ownership stagedOwnership = readOwnership(execution.stagedDir());
String stagedHash = directoryHash(execution.stagedDir());
if (!Objects.equals(stagedHash, execution.stagedHash())
|| !ownershipMetadataMatches(stagedOwnership, execution.entry(), stagedHash)) {
throw new IOException("Iris datapack staging changed before installation commit for "
+ execution.entry().id);
}
}
for (InstallPlan plan : execution.publishedPlans()) {
verifyDesiredInstallSnapshot(plan.target(), plan, "published datapack target");
}
for (InstallPlan plan : execution.unchangedPlans()) {
verifyOriginalInstallSnapshot(plan.target(), plan, "unchanged datapack target");
}
execution.markVerified();
}
static void finishInstallExecution(InstallExecution execution) throws IOException {
if (!execution.verified()) {
throw new IOException("Datapack install cannot commit before final verification");
}
if (execution.coordinator() == null) {
return;
}
@@ -2118,6 +2182,17 @@ public final class DatapackIngestService {
) throws IOException {
ensureInstallTargetRoot(worldFolder);
File target = new File(worldFolder, entry.id);
InstallPlan unchanged = tryPrepareUnchangedManagedInstall(
stagedDir,
worldFolder,
target,
entry,
stagedHash,
stripOverrides,
verifiedStagingInstall);
if (unchanged != null) {
return unchanged;
}
boolean canonicalStagingInstall = verifiedStagingInstall != null
&& verifiedStagingInstall.isCanonicalInstall(worldFolder, target);
boolean legacyReplacementAuthorized = canonicalStagingInstall
@@ -2134,16 +2209,22 @@ public final class DatapackIngestService {
String scratchRootFileIdentity = directoryIdentity(pendingRoot);
IO.copyDirectory(stagedDir.toPath(), pending.toPath());
Files.deleteIfExists(new File(pending, OWNERSHIP_MARKER).toPath());
if (!Objects.equals(stagedHash, directoryHash(pending))) {
String copiedHash = directoryHash(pending);
if (!Objects.equals(stagedHash, copiedHash)) {
throw new IOException("Datapack staging changed or copied incompletely while preparing " + entry.id);
}
Files.deleteIfExists(new File(pending, OVERRIDES_STRIPPED_MARKER).toPath());
boolean removedOverrideMarker = Files.deleteIfExists(
new File(pending, OVERRIDES_STRIPPED_MARKER).toPath());
if (stripOverrides) {
stripVanillaStructureOverrides(pending);
writeMarker(new File(pending, OVERRIDES_STRIPPED_MARKER));
}
validatePackMetadata(pending);
writeOwnership(pending, entry);
if (!stripOverrides && !removedOverrideMarker) {
writeOwnership(pending, entry, copiedHash);
} else {
writeOwnership(pending, entry);
}
validateInstallTree(pending, worldFolder, "Prepared datapack install");
Ownership desiredOwnership = readOwnership(pending);
String desiredHash = desiredOwnership.contentHash;
@@ -2243,6 +2324,69 @@ public final class DatapackIngestService {
}
}
private static InstallPlan tryPrepareUnchangedManagedInstall(
File stagedDir,
File worldFolder,
File target,
Entry entry,
String stagedHash,
boolean stripOverrides,
VerifiedStagingInstall verifiedStagingInstall
) throws IOException {
if (verifiedStagingInstall != null
|| stripOverrides
|| pathExists(new File(stagedDir, OVERRIDES_STRIPPED_MARKER).toPath(),
"staged datapack override marker")
|| !pathExists(target.toPath(), "datapack install target")) {
return null;
}
if (Files.isSymbolicLink(target.toPath())
|| !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath());
}
Ownership ownership = readOwnershipOrNull(target);
if (ownership == null) {
return null;
}
if (!entry.id.equals(ownership.id)) {
throw new IOException("Datapack ownership mismatch at " + target.getPath());
}
if (!ownershipMetadataMatches(ownership, entry, stagedHash)) {
return null;
}
validateInstallTree(target, worldFolder, "Existing datapack install");
removeFinderMetadata(target);
String currentHash = directoryHash(target);
if (!Objects.equals(currentHash, stagedHash)) {
return null;
}
String markerHash = ownershipMarkerFingerprint(target);
String identity = directoryIdentity(target);
File pendingRoot = installScratchRoot(worldFolder);
return new InstallPlan(
target,
new File(pendingRoot, entry.id + "-" + UUID.randomUUID()),
new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID()),
pendingRoot,
true,
false,
false,
currentHash,
currentHash,
markerHash,
markerHash,
identity,
identity,
realDirectoryPath(worldFolder, "datapack target root"),
"",
directoryIdentity(worldFolder),
"",
entry.id,
entry.url,
null
);
}
private static File installScratchRoot(File targetFolder) {
File parent = targetFolder.getParentFile();
return new File(parent == null ? targetFolder : parent, ".iris-datapack-install");
@@ -2550,10 +2694,16 @@ public final class DatapackIngestService {
}
static void writeOwnership(File directory, Entry entry) throws IOException {
writeOwnership(directory, entry, directoryHash(directory));
}
private static void writeOwnership(File directory, Entry entry, String contentHash) throws IOException {
if (!isValidManagedId(entry.id) || entry.url == null || entry.url.isBlank()) {
throw new IOException("Invalid Iris datapack ownership identity for " + directory.getPath());
}
String contentHash = directoryHash(directory);
if (contentHash == null || contentHash.isBlank()) {
throw new IOException("Missing Iris datapack ownership content hash for " + directory.getPath());
}
Ownership ownership = new Ownership(
OWNERSHIP_SCHEMA,
entry.id,
@@ -2640,6 +2790,10 @@ public final class DatapackIngestService {
}
static boolean isUsableStaging(File stagedDir, Entry entry) {
return inspectUsableStaging(stagedDir, entry).usable();
}
private static StagingInspection inspectUsableStaging(File stagedDir, Entry entry) {
try {
validateManagedDirectory(stagedDir, entry.id);
Ownership ownership = readOwnership(stagedDir);
@@ -2649,22 +2803,28 @@ public final class DatapackIngestService {
|| !Objects.equals(ownership.versionNumber, entry.versionNumber)
|| !Objects.equals(ownership.sha1, entry.sha1)
|| !Objects.equals(ownership.contentHash, contentHash)) {
return false;
return new StagingInspection(false, false, false);
}
PackResources resources = scanPackResources(stagedDir);
List<String> previousStructureKeys = copyList(entry.structureKeys);
List<String> previousTemplateKeys = copyList(entry.templateKeys);
boolean ownershipCorrected = false;
if (!copyList(resources.structureKeys).equals(copyList(ownership.structureKeys))
|| !copyList(resources.templateKeys).equals(copyList(ownership.templateKeys))) {
Entry corrected = copyEntry(entry);
corrected.structureKeys = resources.structureKeys;
corrected.templateKeys = resources.templateKeys;
writeOwnership(stagedDir, corrected);
ownershipCorrected = true;
}
entry.structureKeys = resources.structureKeys;
entry.templateKeys = resources.templateKeys;
return true;
boolean manifestChanged = !previousStructureKeys.equals(copyList(entry.structureKeys))
|| !previousTemplateKeys.equals(copyList(entry.templateKeys));
return new StagingInspection(true, ownershipCorrected, manifestChanged);
} catch (IOException e) {
IrisLogging.warn("Ignoring unusable Iris datapack staging at " + stagedDir.getPath() + ": " + e.getMessage());
return false;
return new StagingInspection(false, false, false);
}
}
@@ -2697,6 +2857,14 @@ public final class DatapackIngestService {
}
}
private static boolean sameDatapackVolume(
Path root,
FileStore rootStore,
Path entry
) throws IOException {
return sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry));
}
private static String directoryHash(File root) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
@@ -2709,10 +2877,7 @@ public final class DatapackIngestService {
int pathCount = 0;
while (iterator.hasNext()) {
Path path = iterator.next();
if (path.equals(rootPath)) {
continue;
}
if (path.equals(rootMarker)) {
if (path.equals(rootPath) || path.equals(rootMarker)) {
continue;
}
if (isFinderMetadata(path)) {
@@ -2737,7 +2902,7 @@ public final class DatapackIngestService {
}
}
entries.sort(Comparator.comparing(path -> rootPath.relativize(path).toString()));
byte[] buffer = new byte[8192];
byte[] buffer = new byte[HASH_BUFFER_BYTES];
long totalBytes = 0;
for (Path entry : entries) {
String relative = rootPath.relativize(entry).toString().replace(File.separatorChar, '/');
@@ -2885,36 +3050,56 @@ public final class DatapackIngestService {
return true;
}
private static void autoImportDatapackStructures() {
private static boolean autoImportDatapackStructures() {
TRANSACTION_LOCK.lock();
try {
autoImportDatapackStructuresLocked();
return autoImportDatapackStructuresLocked();
} finally {
TRANSACTION_LOCK.unlock();
}
}
private static void autoImportDatapackStructuresLocked() {
private static boolean autoImportDatapackStructuresLocked() {
boolean autoImportEnabled = IrisSettings.get().getGeneral().autoImportDatapackStructures;
File root = IrisPlatforms.get().dataFolder("datapacks");
boolean recovered;
try {
recoverTransactions(root, ServerConfigurator.getDatapacksFolder());
recovered = recoverTransactions(root, ServerConfigurator.getDatapacksFolder());
} catch (IOException e) {
IrisLogging.reportError("Automatic datapack structure import blocked by incomplete transaction recovery.", e);
return;
return false;
}
Manifest manifest = readManifest(root);
if (manifest.entries.isEmpty()) {
return;
return recovered;
}
Map<String, Entry> manifestEntriesByUrl = new HashMap<>();
Map<String, Entry> entriesByUrl = new HashMap<>();
File stagingRoot = new File(root, "staging");
for (Entry entry : manifest.entries) {
if (entry.url != null) {
manifestEntriesByUrl.put(entry.url, entry);
}
if (entry.url != null && isUsableStaging(new File(stagingRoot, entry.id), entry)) {
}
List<IrisData> packs;
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
packs = stream.filter(Objects::nonNull).toList();
}
if (!autoImportEnabled && !hasRemovedImportState(packs, manifest.entries)) {
return recovered;
}
Map<String, Entry> entriesByUrl = new HashMap<>();
File stagingRoot = new File(root, "staging");
boolean stagingStateChanged = false;
boolean manifestStagingMetadataChanged = false;
for (Entry entry : manifest.entries) {
if (entry.url == null) {
continue;
}
StagingInspection inspection = inspectUsableStaging(new File(stagingRoot, entry.id), entry);
stagingStateChanged |= inspection.ownershipCorrected();
manifestStagingMetadataChanged |= inspection.manifestChanged();
if (inspection.usable()) {
entriesByUrl.put(entry.url, entry);
}
}
@@ -2924,10 +3109,6 @@ public final class DatapackIngestService {
int cleanupTargets = 0;
Set<String> completedUrls = new HashSet<>();
Set<String> failedUrls = new HashSet<>();
List<IrisData> packs;
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
packs = stream.filter(Objects::nonNull).toList();
}
for (IrisData data : packs) {
Set<String> configured = configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
@@ -3055,16 +3236,33 @@ public final class DatapackIngestService {
}
}
if (attemptedPacks == 0 && cleanupTargets == 0) {
return;
if (manifestStagingMetadataChanged) {
writeManifest(root, manifest);
}
return recovered || stagingStateChanged || manifestStagingMetadataChanged;
}
writeManifest(root, manifest);
if (attemptedPacks == 0) {
IrisLogging.info("Datapack editable-import cleanup reconciled " + cleanupTargets + " removed source target(s).");
return;
return true;
}
IrisLogging.info("Datapack structure import refreshed " + completedUrls.size() + " source(s) across "
+ completedPacks + "/" + attemptedPacks
+ " pack(s). Reference the imported keys from a 'structures' placement to position them manually.");
return true;
}
private static boolean hasRemovedImportState(List<IrisData> packs, List<Entry> entries) {
for (IrisData data : packs) {
Set<String> configured = configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
for (Entry entry : entries) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
return true;
}
}
}
return false;
}
static String importRevision(Entry entry) {
@@ -4237,24 +4435,42 @@ public final class DatapackIngestService {
private static void validateScratchTree(Path root) throws IOException {
FileStore rootStore = Files.getFileStore(root);
try (Stream<Path> paths = Files.walk(root)) {
List<Path> entries = paths.limit(MAX_MANAGED_PATHS + 1L).toList();
if (entries.size() > MAX_MANAGED_PATHS) {
throw new IOException("Datapack scratch contains too many paths: " + root);
}
for (Path entry : entries) {
BasicFileAttributes attributes = Files.readAttributes(
entry, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
int[] pathCount = new int[]{0};
Files.walkFileTree(root, new SimpleFileVisitor<>() {
private FileVisitResult inspect(Path entry, BasicFileAttributes attributes) throws IOException {
pathCount[0]++;
if (pathCount[0] > MAX_MANAGED_PATHS) {
throw new IOException("Datapack scratch contains too many paths: " + root);
}
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| (!attributes.isDirectory() && !attributes.isRegularFile())) {
throw new IOException("Datapack scratch contains an unsupported file: " + entry);
}
if (!sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry))) {
if (!sameDatapackVolume(root, rootStore, entry)) {
throw new IOException("Datapack scratch crosses a filesystem boundary: " + entry);
}
return FileVisitResult.CONTINUE;
}
}
@Override
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) throws IOException {
return inspect(directory, attributes);
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
return inspect(file, attributes);
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to inspect datapack scratch entry: " + file, failure);
}
});
}
private static boolean sameScratchVolume(Path first, Path second) throws IOException {
@@ -5450,7 +5666,76 @@ public final class DatapackIngestService {
FAILED
}
record InstallExecution(InstallResult result, DatapackCoordinator coordinator) {
static final class InstallExecution {
private final InstallResult result;
private final DatapackCoordinator coordinator;
private final File stagedDir;
private final Entry entry;
private final String stagedHash;
private final boolean verifyStagedSource;
private final List<InstallPlan> publishedPlans;
private final List<InstallPlan> unchangedPlans;
private boolean verified;
private InstallExecution(
InstallResult result,
DatapackCoordinator coordinator,
File stagedDir,
Entry entry,
String stagedHash,
boolean verifyStagedSource,
List<InstallPlan> publishedPlans,
List<InstallPlan> unchangedPlans
) {
this.result = result;
this.coordinator = coordinator;
this.stagedDir = stagedDir;
this.entry = copyEntry(entry);
this.stagedHash = stagedHash;
this.verifyStagedSource = verifyStagedSource;
this.publishedPlans = List.copyOf(publishedPlans);
this.unchangedPlans = List.copyOf(unchangedPlans);
}
InstallResult result() {
return result;
}
private DatapackCoordinator coordinator() {
return coordinator;
}
private File stagedDir() {
return stagedDir;
}
private Entry entry() {
return entry;
}
private String stagedHash() {
return stagedHash;
}
private boolean verifyStagedSource() {
return verifyStagedSource;
}
private List<InstallPlan> publishedPlans() {
return publishedPlans;
}
private List<InstallPlan> unchangedPlans() {
return unchangedPlans;
}
private boolean verified() {
return verified;
}
private void markVerified() {
verified = true;
}
}
private record PackResources(
@@ -5460,6 +5745,13 @@ public final class DatapackIngestService {
) {
}
private record StagingInspection(
boolean usable,
boolean ownershipCorrected,
boolean manifestChanged
) {
}
public record StructureScopeResources(
String source,
List<String> structureKeys,
@@ -5840,6 +6132,9 @@ public final class DatapackIngestService {
) {
}
private record MetadataEntry(Path path, String relativePath) {
}
private record DirectoryMove(
File target,
File backup,
@@ -59,6 +59,7 @@ import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public interface INMSBinding {
boolean hasTile(Material material);
@@ -279,6 +280,10 @@ public interface INMSBinding {
throw new UnsupportedOperationException("The active NMS binding does not support current Paper world data staging.");
}
default boolean awaitServerShutdownBoundary(long timeout, TimeUnit unit) {
return true;
}
KMap<Material, List<BlockProperty>> getBlockProperties();
private void validateDimensionTypes(WorldCreator c) {
@@ -0,0 +1,59 @@
package art.arcane.iris.core.nms;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
public final class ServerShutdownBoundary {
private static final long MAX_JOIN_SLICE_MILLIS = 1000L;
private ServerShutdownBoundary() {
}
public static boolean await(
BooleanSupplier boundaryReached,
Thread serverThread,
long timeout,
TimeUnit unit
) {
BooleanSupplier reached = Objects.requireNonNull(boundaryReached, "Server shutdown boundary");
Thread activeServerThread = Objects.requireNonNull(serverThread, "Server thread");
TimeUnit activeUnit = Objects.requireNonNull(unit, "Server shutdown timeout unit");
if (reached.getAsBoolean()) {
return true;
}
if (activeServerThread == Thread.currentThread()) {
return false;
}
long timeoutNanos = Math.max(0L, activeUnit.toNanos(timeout));
long started = System.nanoTime();
boolean interrupted = false;
while (!reached.getAsBoolean()) {
long remaining = timeoutNanos - (System.nanoTime() - started);
if (remaining <= 0L || !activeServerThread.isAlive()) {
restoreInterrupt(interrupted);
return reached.getAsBoolean();
}
long joinMillis = Math.max(
1L,
Math.min(MAX_JOIN_SLICE_MILLIS, TimeUnit.NANOSECONDS.toMillis(remaining))
);
try {
activeServerThread.join(joinMillis);
} catch (InterruptedException e) {
interrupted = true;
}
}
restoreInterrupt(interrupted);
return true;
}
private static void restoreInterrupt(boolean interrupted) {
if (interrupted) {
Thread.currentThread().interrupt();
}
}
}
@@ -19,16 +19,21 @@
package art.arcane.iris.core.pack;
import java.io.IOException;
import java.nio.file.NoSuchFileException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public final class PackValidationRegistry {
private static final Map<String, PackValidationResult> RESULTS = new ConcurrentHashMap<>();
private static final Map<Path, PackValidationResult> ROOT_RESULTS = new ConcurrentHashMap<>();
private static final Map<Path, RootState> ROOT_STATES = new ConcurrentHashMap<>();
private PackValidationRegistry() {
}
@@ -44,7 +49,78 @@ public final class PackValidationRegistry {
if (packRoot == null || result == null) {
return;
}
ROOT_RESULTS.put(normalize(packRoot), result);
publish(normalize(packRoot), new RootValidation(result, ""));
}
public static void publish(Path packRoot, PackValidationResult result, String contentFingerprint) {
if (packRoot == null || result == null || contentFingerprint == null || contentFingerprint.isBlank()) {
return;
}
publish(normalize(packRoot), new RootValidation(result, contentFingerprint));
}
public static PackValidationResult publishMatchingCopy(
Path sourceRoot,
Path targetRoot,
String copiedContentFingerprint
) {
if (sourceRoot == null || targetRoot == null
|| copiedContentFingerprint == null || copiedContentFingerprint.isBlank()) {
return null;
}
RootValidation sourceValidation = matchingValidation(sourceRoot, copiedContentFingerprint);
if (sourceValidation == null) {
return null;
}
publish(normalize(targetRoot), sourceValidation);
return sourceValidation.result();
}
public static RootMutation beginRootMutation(Path packRoot) {
Path normalizedRoot = normalize(Objects.requireNonNull(packRoot, "Pack root"));
AtomicReference<RootMutation> mutation = new AtomicReference<>();
ROOT_STATES.compute(normalizedRoot, (path, current) -> {
if (current != null && current.mutating()) {
throw new IllegalStateException("Iris pack validation is already mutating " + normalizedRoot);
}
long generation = nextGeneration(current);
mutation.set(new RootMutation(normalizedRoot, generation));
return new RootState(generation, true, null);
});
return mutation.get();
}
public static ValidationTicket tryBeginValidation(Path packRoot) {
Path normalizedRoot = normalize(Objects.requireNonNull(packRoot, "Pack root"));
AtomicReference<ValidationTicket> ticket = new AtomicReference<>();
ROOT_STATES.compute(normalizedRoot, (path, current) -> {
RootState state = current == null ? new RootState(0L, false, null) : current;
if (!state.mutating()) {
ticket.set(new ValidationTicket(normalizedRoot, state.generation()));
}
return state;
});
return ticket.get();
}
public static boolean publishIfCurrent(ValidationTicket ticket, PackValidationResult result) {
if (ticket == null || result == null) {
return false;
}
AtomicBoolean published = new AtomicBoolean();
ROOT_STATES.compute(ticket.packRoot, (path, current) -> {
if (current == null
|| current.mutating()
|| current.generation() != ticket.generation) {
return current;
}
published.set(true);
return new RootState(
current.generation(),
false,
new RootValidation(result, ""));
});
return published.get();
}
public static PackValidationResult get(String packName) {
@@ -58,7 +134,10 @@ public final class PackValidationRegistry {
if (packRoot == null) {
return null;
}
return ROOT_RESULTS.get(normalize(packRoot));
RootState state = ROOT_STATES.get(normalize(packRoot));
return state == null || state.mutating() || state.validation() == null
? null
: state.validation().result();
}
public static PackValidationResult requireLoadable(String packName) {
@@ -117,22 +196,158 @@ public final class PackValidationRegistry {
if (packRoot == null) {
return;
}
ROOT_RESULTS.remove(normalize(packRoot));
Path normalizedRoot = normalize(packRoot);
ROOT_STATES.compute(normalizedRoot, (path, current) -> {
if (current != null && current.mutating()) {
return current;
}
return new RootState(nextGeneration(current), false, null);
});
}
public static void clear() {
RESULTS.clear();
ROOT_RESULTS.clear();
ROOT_STATES.clear();
}
private static Path normalize(Path packRoot) {
Path normalizedRoot = packRoot.toAbsolutePath().normalize();
try {
return normalizedRoot.toRealPath();
} catch (NoSuchFileException exception) {
return normalizedRoot;
Path existing = normalizedRoot;
List<Path> missingNames = new ArrayList<>();
while (existing != null && !Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) {
Path name = existing.getFileName();
if (name != null) {
missingNames.add(name);
}
existing = existing.getParent();
}
if (existing == null) {
return normalizedRoot;
}
Path resolved = existing.toRealPath();
for (int index = missingNames.size() - 1; index >= 0; index--) {
resolved = resolved.resolve(missingNames.get(index));
}
return resolved.normalize();
} catch (IOException exception) {
throw new IllegalArgumentException("Unable to resolve Iris pack root: " + normalizedRoot, exception);
}
}
private static void publish(Path normalizedRoot, RootValidation validation) {
ROOT_STATES.compute(normalizedRoot, (path, current) -> {
if (current != null && current.mutating()) {
throw new IllegalStateException("Iris pack validation is mutating " + normalizedRoot);
}
return new RootState(nextGeneration(current), false, validation);
});
}
private static RootValidation matchingValidation(Path sourceRoot, String copiedContentFingerprint) {
Path normalizedSource = normalize(sourceRoot);
RootState sourceState = ROOT_STATES.get(normalizedSource);
if (sourceState == null
|| sourceState.mutating()
|| sourceState.validation() == null
|| !copiedContentFingerprint.equals(sourceState.validation().contentFingerprint())) {
return null;
}
return sourceState.validation();
}
private static long nextGeneration(RootState current) {
return current == null ? 1L : Math.incrementExact(current.generation());
}
private static void closeMutation(Path packRoot, long generation) {
ROOT_STATES.computeIfPresent(packRoot, (path, current) -> {
if (!current.mutating() || current.generation() != generation) {
return current;
}
return new RootState(generation, false, null);
});
}
public static final class RootMutation implements AutoCloseable {
private final Path packRoot;
private final long generation;
private RootValidation pendingValidation;
private boolean closed;
private RootMutation(Path packRoot, long generation) {
this.packRoot = packRoot;
this.generation = generation;
}
public synchronized PackValidationResult stageMatchingCopy(
Path sourceRoot,
String copiedContentFingerprint
) {
requireOpen();
RootValidation matching = matchingValidation(sourceRoot, copiedContentFingerprint);
if (matching == null) {
return null;
}
pendingValidation = matching;
return matching.result();
}
public synchronized void stage(PackValidationResult result) {
requireOpen();
pendingValidation = new RootValidation(
Objects.requireNonNull(result, "Pack validation result"),
"");
}
public synchronized void commit() {
requireOpen();
if (pendingValidation == null) {
throw new IllegalStateException("No pack validation is staged for " + packRoot);
}
AtomicBoolean published = new AtomicBoolean();
ROOT_STATES.computeIfPresent(packRoot, (path, current) -> {
if (!current.mutating() || current.generation() != generation) {
return current;
}
published.set(true);
return new RootState(generation, false, pendingValidation);
});
if (!published.get()) {
throw new IllegalStateException("Iris pack validation mutation lost ownership of " + packRoot);
}
closed = true;
}
@Override
public synchronized void close() {
if (closed) {
return;
}
closed = true;
closeMutation(packRoot, generation);
}
private void requireOpen() {
if (closed) {
throw new IllegalStateException("Iris pack validation mutation is already closed for " + packRoot);
}
}
}
public static final class ValidationTicket {
private final Path packRoot;
private final long generation;
private ValidationTicket(Path packRoot, long generation) {
this.packRoot = packRoot;
this.generation = generation;
}
}
private record RootValidation(PackValidationResult result, String contentFingerprint) {
}
private record RootState(long generation, boolean mutating, RootValidation validation) {
}
}
@@ -210,6 +210,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private final JigsawStudioTripleSneakTracker tripleSneakTracker = new JigsawStudioTripleSneakTracker();
private final JigsawStudioToolCodec toolCodec = new JigsawStudioToolCodec();
private final JigsawStudioPreviewRenderer previewRenderer = new JigsawStudioPreviewRenderer();
private final AtomicBoolean disableStarted = new AtomicBoolean();
private final Object saveLifecycleLock = new Object();
private final Set<UUID> savesInProgress = new HashSet<>();
private final Set<UUID> graphMutationsInProgress = new HashSet<>();
@@ -234,6 +235,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
@Override
public void onEnable() {
disableStarted.set(false);
enabled = true;
menuController = new JigsawStudioMenuController(BukkitPlatform.volmitPlugin(), this);
INSTANCE = this;
@@ -241,13 +243,32 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
@Override
public void onDisable() {
finalizeAllJigsawTileWatches();
drainAutosavesBeforeDisable();
quiesceForServerShutdown();
}
public void quiesceForServerShutdown() {
if (!disableStarted.compareAndSet(false, true)) {
return;
}
try {
finalizeAllJigsawTileWatches();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to finalize Jigsaw Studio tile watches during shutdown.", exception);
}
try {
drainAutosavesBeforeDisable();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to drain Jigsaw Studio autosaves during shutdown.", exception);
}
enabled = false;
JigsawStudioMenuController activeMenuController = menuController;
menuController = null;
if (activeMenuController != null) {
activeMenuController.closeAll();
try {
activeMenuController.closeAll();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to close Jigsaw Studio menus during shutdown.", exception);
}
}
particlesDisabled.clear();
visualizationLoops.clear();
@@ -259,7 +280,11 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
toolConfirmations.clear();
tripleSneakTracker.clearAll();
evaluations.clear();
previewRenderer.removeAll();
try {
previewRenderer.removeAll();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to remove Jigsaw Studio previews during shutdown.", exception);
}
reopenRequiredRequests.clear();
unregisterRetries.clear();
unregisterDrainWarnings.clear();
@@ -276,6 +301,9 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
}
public void register(Engine engine, JigsawStudioGenerator generator) {
if (!enabled || disableStarted.get()) {
return;
}
Engine activeEngine = Objects.requireNonNull(engine, "Jigsaw Studio engine");
JigsawStudioGenerator activeGenerator = Objects.requireNonNull(generator, "Jigsaw Studio generator");
World world = BukkitWorldBinding.world(activeEngine.getTarget().getWorld());
@@ -306,6 +334,9 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
new AtomicLong());
UUID displacedRequestId = null;
synchronized (saveLifecycleLock) {
if (!enabled || disableStarted.get()) {
return;
}
ActiveStudio previous = studios.get(world.getUID());
if (previous != null && previous.generator() == activeGenerator) {
return;
@@ -338,7 +369,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
}
public void activationCommitted(World world, UUID requestId) {
if (world == null || requestId == null) {
if (!enabled || disableStarted.get() || world == null || requestId == null) {
return;
}
ActiveStudio studio = studios.get(world.getUID());
@@ -355,7 +386,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
int chunkX,
int chunkZ
) {
if (engine == null || generator == null) {
if (!enabled || disableStarted.get() || engine == null || generator == null) {
return;
}
World world = BukkitWorldBinding.world(engine.getTarget().getWorld());
@@ -363,7 +394,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
return;
}
ActiveStudio studio = studios.get(world.getUID());
if (studio == null || studio.engine() != engine || studio.generator() != generator) {
if (!enabled || disableStarted.get()
|| studio == null || studio.engine() != engine || studio.generator() != generator) {
return;
}
markChunkAvailable(studio, chunkX, chunkZ);
@@ -5259,7 +5291,11 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private void finalizeAllJigsawTileWatches() {
for (JigsawTileWatch watch : List.copyOf(jigsawTileWatches.values())) {
finalizeJigsawTileWatch(watch);
try {
finalizeJigsawTileWatch(watch);
} catch (Throwable exception) {
IrisLogging.reportError("Failed to finalize a Jigsaw Studio tile watch during shutdown.", exception);
}
}
}
@@ -5655,7 +5691,14 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private void drainAutosavesBeforeDisable() {
for (ActiveStudio studio : List.copyOf(studios.values())) {
drainAutosavesBeforeRemoval(studio);
try {
drainAutosavesBeforeRemoval(studio);
} catch (Throwable exception) {
IrisLogging.reportError(
"Failed to drain Jigsaw Studio autosaves in world "
+ studio.worldId() + " during shutdown.",
exception);
}
}
if (!autosaves.isEmpty()) {
IrisLogging.warn("Jigsaw Studio disabled with %d autosave operation(s) still pending after the final drain attempt",
@@ -29,6 +29,7 @@ import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackDownloadExecution;
import art.arcane.iris.core.pack.PackDownloader;
@@ -64,12 +65,14 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
@@ -82,7 +85,6 @@ import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import art.arcane.iris.core.localization.BukkitRuntimeMessages;
import art.arcane.iris.core.localization.IrisLanguage;
@@ -198,6 +200,7 @@ public class StudioSVC implements IrisService {
Path parent = target.getParent();
Path stage = null;
AtomicDirectoryPublisher.Publication publication = null;
PackValidationRegistry.RootMutation validationMutation = null;
IrisData previousData = IrisData.getLoaded(target.toFile()).orElse(null);
IrisData createdData = null;
boolean refreshedPreviousData = false;
@@ -207,10 +210,7 @@ public class StudioSVC implements IrisService {
throw new IOException("World pack target has no parent: " + target);
}
Files.createDirectories(parent);
if (!replaceExisting
&& (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target))) {
throw new FileAlreadyExistsException(target.toString());
}
requireSafePublicationTarget(target, replaceExisting);
stage = Files.createTempDirectory(parent, ".pack.installing-");
copyPackTree(source, stage);
@@ -223,9 +223,18 @@ public class StudioSVC implements IrisService {
} finally {
stagedData.close();
}
validationMutation = PackValidationRegistry.beginRootMutation(target);
requireSafePublicationTarget(target, replaceExisting);
publication = AtomicDirectoryPublisher.publish(stage, target);
stage = null;
validatePublishedPack(target);
String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile());
PackValidationResult publishedValidation =
validatePublishedPack(target, source, copiedFingerprint, validationMutation);
if (!publishedValidation.isLoadable()) {
throw new BrokenPackException(
target.toString(),
publishedValidation.getBlockingErrors());
}
IrisData installedData;
// Live engines only ever attach to detached openRuntime loaders, never to the
@@ -249,6 +258,7 @@ public class StudioSVC implements IrisService {
throw new IOException("Published pack does not contain a loadable dimension '" + dimensionKey + "'.");
}
publication.commit();
validationMutation.commit();
try {
publication.cleanupBackup();
} catch (IOException cleanupFailure) {
@@ -275,6 +285,9 @@ public class StudioSVC implements IrisService {
sender.sendMessage("Failed to install studio pack '" + dimensionKey + "': " + errorDetail(e));
return null;
} finally {
if (validationMutation != null) {
validationMutation.close();
}
if (stage != null) {
try {
AtomicDirectoryPublisher.deleteTree(stage);
@@ -289,11 +302,56 @@ public class StudioSVC implements IrisService {
PackValidationRegistry.remove(packRoot);
}
static void requireSafePublicationTarget(Path target, boolean replaceExisting) throws IOException {
if (Files.isSymbolicLink(target)) {
throw new IOException("World pack target is a symbolic link: " + target);
}
if (!replaceExisting && Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw new FileAlreadyExistsException(target.toString());
}
}
static PackValidationResult validatePublishedPack(Path packRoot) {
invalidatePackValidation(packRoot);
PackValidationResult result = PackValidator.validate(packRoot.toFile());
PackValidationRegistry.publish(packRoot, result);
return PackValidationRegistry.requireLoadable(packRoot);
try (PackValidationRegistry.RootMutation mutation =
PackValidationRegistry.beginRootMutation(packRoot)) {
PackValidationResult result = PackValidator.validate(packRoot.toFile());
mutation.stage(result);
mutation.commit();
return PackValidationRegistry.requireLoadable(packRoot);
}
}
static PackValidationResult validatePublishedPack(
Path packRoot,
Path validatedSource,
String copiedContentFingerprint
) {
try (PackValidationRegistry.RootMutation mutation =
PackValidationRegistry.beginRootMutation(packRoot)) {
PackValidationResult result = validatePublishedPack(
packRoot,
validatedSource,
copiedContentFingerprint,
mutation);
mutation.commit();
return PackValidationRegistry.requireLoadable(packRoot);
}
}
private static PackValidationResult validatePublishedPack(
Path packRoot,
Path validatedSource,
String copiedContentFingerprint,
PackValidationRegistry.RootMutation mutation
) {
PackValidationResult result = mutation.stageMatchingCopy(
validatedSource,
copiedContentFingerprint);
if (result == null) {
result = PackValidator.validate(packRoot.toFile());
mutation.stage(result);
}
return result;
}
static void rollbackFailedPublication(
@@ -318,7 +376,6 @@ public class StudioSVC implements IrisService {
}
static Path resolveSafePackSource(File sourceFolder) throws IOException {
PackDirectoryResolver.requireSafePackTree(sourceFolder);
Path source = sourceFolder.toPath().toAbsolutePath().normalize().toRealPath();
if (!Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(source)) {
throw new IOException("Source pack is missing or unsafe: " + sourceFolder);
@@ -1202,25 +1259,85 @@ public class StudioSVC implements IrisService {
}
static void copyPackTree(Path source, Path target) throws IOException {
try (Stream<Path> entries = Files.walk(source)) {
for (Path entry : entries.sorted(Comparator.naturalOrder()).toList()) {
if (Files.isSymbolicLink(entry)) {
throw new IOException("Pack contains a symbolic link: " + entry);
}
Path destination = target.resolve(source.relativize(entry)).normalize();
if (!destination.startsWith(target)) {
throw new IOException("Pack entry escapes its installation stage: " + entry);
}
if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination);
} else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination.getParent());
Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES);
} else {
throw new IOException("Pack contains an unsupported entry: " + entry);
}
}
Path normalizedSource = source.toRealPath();
if (!Files.isDirectory(normalizedSource, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Pack source is not a directory: " + normalizedSource);
}
Path requestedTarget = target.toAbsolutePath().normalize();
if (Files.isSymbolicLink(requestedTarget)) {
throw new IOException("Pack installation stage is a symbolic link: " + requestedTarget);
}
Path normalizedTarget;
if (Files.exists(requestedTarget, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isDirectory(requestedTarget, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Pack installation stage is not a directory: " + requestedTarget);
}
normalizedTarget = requestedTarget.toRealPath();
} else {
Path parent = Objects.requireNonNull(
requestedTarget.getParent(),
"Pack installation stage parent");
normalizedTarget = parent.toRealPath().resolve(requestedTarget.getFileName()).normalize();
}
if (normalizedTarget.startsWith(normalizedSource)
|| normalizedSource.startsWith(normalizedTarget)) {
throw new IOException("Pack source and installation stage overlap: "
+ normalizedSource + " and " + normalizedTarget);
}
Files.walkFileTree(normalizedSource, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(
Path directory,
BasicFileAttributes attributes
) throws IOException {
if (attributes.isSymbolicLink()) {
throw new IOException("Pack contains a symbolic link: " + directory);
}
if (!directory.equals(normalizedSource)
&& normalizedSource.relativize(directory).getNameCount() == 1
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
return FileVisitResult.SKIP_SUBTREE;
}
Files.createDirectories(copyDestination(
normalizedSource,
normalizedTarget,
directory));
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
if (attributes.isSymbolicLink()) {
throw new IOException("Pack contains a symbolic link: " + file);
}
if (!attributes.isRegularFile()) {
throw new IOException("Pack contains an unsupported entry: " + file);
}
String fileName = file.getFileName().toString();
if ((normalizedSource.relativize(file).getNameCount() == 1
&& PackDirectoryResolver.isHiddenName(fileName))
|| fileName.endsWith(".code-workspace")) {
return FileVisitResult.CONTINUE;
}
Path destination = copyDestination(normalizedSource, normalizedTarget, file);
Files.createDirectories(Objects.requireNonNull(destination.getParent(), "Pack entry parent"));
Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to copy pack entry: " + file, failure);
}
});
}
private static Path copyDestination(Path source, Path target, Path entry) throws IOException {
Path destination = target.resolve(source.relativize(entry)).normalize();
if (!destination.startsWith(target)) {
throw new IOException("Pack entry escapes its installation stage: " + entry);
}
return destination;
}
static void publishNewDirectory(Path stage, Path target) throws IOException {
@@ -97,6 +97,7 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
@Accessors(fluent = true, chain = true)
public class IrisCreator {
private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L;
private static final long WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS = 60L;
private static final long ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L;
/**
@@ -398,17 +399,42 @@ public class IrisCreator {
return;
}
Throwable failure = awaitTeleportFailure(
teleportFuture,
player.getName(),
WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS,
TimeUnit.SECONDS
);
if (failure != null) {
reportSenderTeleportFailure(player, world, failure);
}
}
static Throwable awaitTeleportFailure(
CompletableFuture<Boolean> teleportFuture,
String playerName,
long timeout,
TimeUnit unit
) {
CompletableFuture<Boolean> requiredFuture = Objects.requireNonNull(teleportFuture, "teleportFuture");
String requiredPlayerName = Objects.requireNonNull(playerName, "playerName");
TimeUnit requiredUnit = Objects.requireNonNull(unit, "unit");
try {
Boolean teleported = teleportFuture.get(60L, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(teleported)) {
reportSenderTeleportFailure(player, world, new IllegalStateException(
"The runtime teleport operation returned false for player \"" + player.getName() + "\"."));
}
Boolean teleported = requiredFuture.get(timeout, requiredUnit);
return Boolean.TRUE.equals(teleported)
? null
: new IllegalStateException(
"The runtime teleport operation returned false for player \"" + requiredPlayerName + "\".");
} catch (TimeoutException e) {
ServerConfigurator.restart("World entry teleport timed out for \"" + world.getName() + "\".");
reportSenderTeleportFailure(player, world, e);
requiredFuture.cancel(false);
return e;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return e;
} catch (ExecutionException e) {
return e.getCause() == null ? e : e.getCause();
} catch (Throwable e) {
reportSenderTeleportFailure(player, world, e);
return e;
}
}
@@ -494,6 +494,15 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
return future;
}
@Override
public void quiesceForServerShutdown() {
Looper activeHotloader = hotloader;
hotloader = null;
if (activeHotloader != null) {
activeHotloader.interrupt();
}
}
@Override
public boolean isStudio() {
return studio;
@@ -66,6 +66,9 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider {
}
}
default void quiesceForServerShutdown() {
}
boolean isStudio();
default boolean isClosing() {
@@ -169,6 +169,38 @@ public class IrisDatapackCompilerInputFingerprintTest {
"compiler-a").isBlank());
}
@Test
public void worldInputRootDiscoveryUsesOnlyCanonicalWorldSnapshots() throws Exception {
Path dataDirectory = tmp.newFolder("canonical-root-data").toPath();
Path serverRoot = tmp.newFolder("canonical-root-server").toPath();
Path canonicalPack = serverRoot.resolve("dimensions/iris/alpha/iris/pack");
Path nestedKeyPack = serverRoot.resolve("dimensions/iris/runtime/studio/iris/pack");
Path incompleteAncestorPack = serverRoot.resolve("dimensions/iris/runtime/iris/pack");
Path nestedKeyDecoy = nestedKeyPack.resolve("region/archive/iris/pack");
Path nestedRegionPack = serverRoot.resolve(
"dimensions/iris/alpha/region/archive/iris/pack");
Path nestedSavedPack = canonicalPack.resolve("objects/saved/iris/pack");
Path customNamespacePack = serverRoot.resolve("dimensions/custom/beta/iris/pack");
Path hiddenNamespacePack = serverRoot.resolve("dimensions/.hidden/beta/iris/pack");
Path hiddenWorldPack = serverRoot.resolve("dimensions/custom/.hidden/iris/pack");
write(canonicalPack.resolve("dimensions/alpha.json"), "{}");
write(nestedKeyPack.resolve("dimensions/studio.json"), "{}");
Files.createDirectories(incompleteAncestorPack);
write(nestedKeyDecoy.resolve("dimensions/decoy.json"), "{}");
write(nestedRegionPack.resolve("dimensions/decoy.json"), "{}");
write(nestedSavedPack.resolve("dimensions/decoy.json"), "{}");
write(customNamespacePack.resolve("dimensions/beta.json"), "{}");
write(hiddenNamespacePack.resolve("dimensions/decoy.json"), "{}");
write(hiddenWorldPack.resolve("dimensions/decoy.json"), "{}");
List<File> compilerRoots = IrisDatapackCompiler.collectCompilerInputRoots(dataDirectory, serverRoot);
assertEquals(List.of(
customNamespacePack.toAbsolutePath().normalize().toFile(),
canonicalPack.toAbsolutePath().normalize().toFile(),
nestedKeyPack.toAbsolutePath().normalize().toFile()), compilerRoots);
}
private Path activePack(String name) throws IOException {
Path pack = tmp.newFolder(name).toPath();
write(pack.resolve("dimensions/overworld.json"), "dimension-a");
@@ -77,6 +77,36 @@ public class ServerConfiguratorDatapackFingerprintTest {
assertNotEquals("Equal-size content changes must alter the fingerprint", before, after);
}
@Test
public void contentSnapshotPublishesExactAggregateAndPerPackFingerprints() throws Exception {
File packsDir = tmp.newFolder("content-snapshot-packs");
Path alphaPack = packsDir.toPath().resolve("alpha");
Path betaPack = packsDir.toPath().resolve("beta");
Path alphaDimension = alphaPack.resolve("dimensions/alpha.json");
Path betaDimension = betaPack.resolve("dimensions/beta.json");
Files.createDirectories(alphaDimension.getParent());
Files.createDirectories(betaDimension.getParent());
Files.writeString(alphaDimension, "alpha-a", StandardCharsets.UTF_8);
Files.writeString(betaDimension, "beta-a", StandardCharsets.UTF_8);
ServerConfigurator.PackContentSnapshot before =
ServerConfigurator.computePackContentSnapshot(packsDir);
assertEquals(ServerConfigurator.computePackFingerprint(packsDir), before.content());
assertEquals(ServerConfigurator.computePackTreeFingerprint(alphaPack.toFile()),
before.packContents().get("alpha"));
assertEquals(ServerConfigurator.computePackTreeFingerprint(betaPack.toFile()),
before.packContents().get("beta"));
Files.writeString(alphaDimension, "alpha-b", StandardCharsets.UTF_8);
ServerConfigurator.PackContentSnapshot after =
ServerConfigurator.computePackContentSnapshot(packsDir);
assertNotEquals(before.content(), after.content());
assertNotEquals(before.packContents().get("alpha"), after.packContents().get("alpha"));
assertEquals(before.packContents().get("beta"), after.packContents().get("beta"));
}
@Test
public void computePackFingerprintChangesWhenFileIsAdded() throws Exception {
Method method = fingerprintMethod();
@@ -110,6 +140,31 @@ public class ServerConfiguratorDatapackFingerprintTest {
assertEquals(before, ServerConfigurator.computePackFingerprint(packsDir));
}
@Test
public void perPackFingerprintIncludesHiddenFilesCopiedIntoWorldSnapshots() throws Exception {
File packsDir = tmp.newFolder("hidden-pack-content");
Path pack = packsDir.toPath().resolve("testpack");
Path visible = pack.resolve("dimensions/overworld.json");
Path hidden = pack.resolve("dimensions/.broken.json");
Files.createDirectories(visible.getParent());
Files.writeString(visible, "visible", StandardCharsets.UTF_8);
Files.writeString(hidden, "hidden-one", StandardCharsets.UTF_8);
ServerConfigurator.PackContentSnapshot before =
ServerConfigurator.computePackContentSnapshot(packsDir);
Files.writeString(hidden, "hidden-two", StandardCharsets.UTF_8);
ServerConfigurator.PackContentSnapshot after =
ServerConfigurator.computePackContentSnapshot(packsDir);
assertNotEquals(before.content(), after.content());
assertNotEquals(
before.packContents().get("testpack"),
after.packContents().get("testpack"));
assertEquals(
after.packContents().get("testpack"),
ServerConfigurator.computePackTreeFingerprint(pack.toFile()));
}
@Test
public void computePackFingerprintIgnoresGeneratedCodeWorkspaceFiles() throws Exception {
File packsDir = tmp.newFolder("workspace-packs");
@@ -117,6 +172,8 @@ public class ServerConfiguratorDatapackFingerprintTest {
Files.createDirectories(dimension.getParent());
Files.writeString(dimension, "authored", StandardCharsets.UTF_8);
String before = ServerConfigurator.computePackFingerprint(packsDir);
String packBefore = ServerConfigurator.computePackTreeFingerprint(
packsDir.toPath().resolve("overworld").toFile());
Path workspace = packsDir.toPath().resolve("overworld/overworld.code-workspace");
Files.writeString(workspace, "{\"folders\":[]}", StandardCharsets.UTF_8);
@@ -128,6 +185,17 @@ public class ServerConfiguratorDatapackFingerprintTest {
assertEquals("Reordered workspace bytes must not alter the fingerprint",
before, ServerConfigurator.computePackFingerprint(packsDir));
Path schema = packsDir.toPath().resolve("overworld/.iris/schema/dimension.json");
Path repositoryObject = packsDir.toPath().resolve("overworld/.git/objects/blob");
Files.createDirectories(schema.getParent());
Files.createDirectories(repositoryObject.getParent());
Files.writeString(schema, "generated schema", StandardCharsets.UTF_8);
Files.writeString(repositoryObject, "repository metadata", StandardCharsets.UTF_8);
assertEquals(before, ServerConfigurator.computePackFingerprint(packsDir));
assertEquals(packBefore, ServerConfigurator.computePackTreeFingerprint(
packsDir.toPath().resolve("overworld").toFile()));
}
@Test
@@ -316,18 +384,20 @@ public class ServerConfiguratorDatapackFingerprintTest {
}
@Test
public void recoveryRunsBeforeFingerprintEarlyReturnAndCompilation() throws Exception {
public void recoveryRunsBeforeRestoredFingerprintReuseHashFallbackAndCompilation() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/ServerConfigurator.java"));
int installIfChanged = source.indexOf("installDataPacksIfChanged(boolean fullInstall)");
int recovery = source.indexOf("DatapackIngestService.reapplyFromStaging", installIfChanged);
int fingerprint = source.indexOf("computeCurrentDatapackCompilerInputFingerprint", recovery);
int restored = source.indexOf("restoredCompilerInputFingerprint()", recovery);
int fingerprint = source.indexOf("computeCurrentDatapackCompilerInputFingerprint", restored);
int earlyReturn = source.indexOf("resultForUnchangedFingerprint", fingerprint);
int compile = source.indexOf("compileDataPacksLocked(", earlyReturn);
int cache = source.indexOf("writeCompilerInputFingerprintCache(cacheFile.toPath(), current)", compile);
assertTrue(recovery >= 0);
assertTrue(fingerprint > recovery);
assertTrue(restored > recovery);
assertTrue(fingerprint > restored);
assertTrue(earlyReturn > fingerprint);
assertTrue(compile > earlyReturn);
assertTrue(cache > compile);
@@ -358,6 +428,38 @@ public class ServerConfiguratorDatapackFingerprintTest {
assertFalse(ServerConfigurator.reusableRuntimeFingerprint(null, "abc"));
}
@Test
public void restoredCompilerInputFingerprintRequiresReadyNonRestartingRuntime() throws Exception {
Field ready = ServerConfigurator.class.getDeclaredField("loadedDatapackRuntimeReady");
Field fingerprint = ServerConfigurator.class.getDeclaredField(
"loadedDatapackCompilerInputFingerprint");
Field restartRequired = ServerConfigurator.class.getDeclaredField("loadedDatapackRestartRequired");
ready.setAccessible(true);
fingerprint.setAccessible(true);
restartRequired.setAccessible(true);
boolean previousReady = ready.getBoolean(null);
String previousFingerprint = (String) fingerprint.get(null);
boolean previousRestartRequired = restartRequired.getBoolean(null);
try {
ready.setBoolean(null, true);
fingerprint.set(null, "restored-fingerprint");
restartRequired.setBoolean(null, false);
assertEquals("restored-fingerprint", ServerConfigurator.restoredCompilerInputFingerprint());
restartRequired.setBoolean(null, true);
assertEquals("", ServerConfigurator.restoredCompilerInputFingerprint());
restartRequired.setBoolean(null, false);
ready.setBoolean(null, false);
assertEquals("", ServerConfigurator.restoredCompilerInputFingerprint());
} finally {
ready.setBoolean(null, previousReady);
fingerprint.set(null, previousFingerprint);
restartRequired.setBoolean(null, previousRestartRequired);
}
}
@Test
public void externalDatapackMutationInvalidatesReadinessAndRetainsComparisonPin() throws Exception {
Field ready = ServerConfigurator.class.getDeclaredField("loadedDatapackRuntimeReady");
@@ -246,6 +246,39 @@ public class DatapackIngestServiceTest {
validated, "26.2", 4000, true, true, validated.urls));
}
@Test
public void startupChecksCheapCacheContextBeforeHashingManagedDatapacks() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java"));
int validation = source.indexOf("public static StartupValidationOutcome validateOnStartup()");
int cacheRead = source.indexOf("readStartupValidationCache", validation);
int contextCheck = source.indexOf("startupValidationContextMatches(", cacheRead);
int fingerprint = source.indexOf("startupValidationFingerprint(", contextCheck);
int fullValidation = source.indexOf("if (autoIngest && !configured.isEmpty())", fingerprint);
assertTrue(validation >= 0);
assertTrue(cacheRead > validation);
assertTrue(contextCheck > cacheRead);
assertTrue(fingerprint > contextCheck);
assertTrue(fullValidation > fingerprint);
}
@Test
public void unchangedPostStartupMaintenanceReturnsBeforeFingerprinting() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java"));
int refresh = source.indexOf(
"refreshStartupValidationAfterMaintenance(boolean maintenanceChanged)");
int unchangedGuard = source.indexOf("if (!maintenanceChanged)", refresh);
int validatedState = source.indexOf("StartupValidationCache validated", refresh);
int fingerprint = source.indexOf("startupValidationFingerprint(", refresh);
assertTrue(refresh >= 0);
assertTrue(unchangedGuard > refresh);
assertTrue(validatedState > unchangedGuard);
assertTrue(fingerprint > unchangedGuard);
}
@Test
public void packMetadataMustContainAValidPackContract() throws Exception {
File valid = temporaryFolder.newFolder("valid");
@@ -733,6 +766,64 @@ public class DatapackIngestServiceTest {
assertTrue(managed.isDirectory());
}
@Test
public void ownershipDirectoryHashRetainsGoldenFramingAndExclusions() throws Exception {
File managed = temporaryFolder.newFolder("directory-hash-golden");
Path data = managed.toPath().resolve("data");
Path example = data.resolve("example");
Files.createDirectories(example);
Files.createDirectory(managed.toPath().resolve("empty"));
Files.write(example.resolve("value.bin"), new byte[]{0, 1, 2, 3, (byte) 0xff});
Files.writeString(
managed.toPath().resolve("pack.mcmeta"),
"{\"pack\":{\"description\":\"golden\",\"pack_format\":88}}",
StandardCharsets.UTF_8);
Files.writeString(managed.toPath().resolve("z.txt"), "Iris\n", StandardCharsets.UTF_8);
Files.writeString(
managed.toPath().resolve(".iris-managed.json"),
"ignored ownership marker",
StandardCharsets.UTF_8);
Files.writeString(managed.toPath().resolve(".DS_Store"), "ignored root metadata");
Files.writeString(data.resolve(".DS_Store"), "ignored nested metadata");
DatapackIngestService.Entry entry = entry("golden", "v1", "1", "sha");
DatapackIngestService.writeOwnership(managed, entry);
String expected = "aa62ee4ed00f0393e637411686082f253ec65ff788839b12c30fc175e5b501fb";
assertEquals(expected, ownershipHash(managed));
Files.writeString(
managed.toPath().resolve(".iris-managed.json"),
"different ignored ownership marker",
StandardCharsets.UTF_8);
Files.writeString(managed.toPath().resolve(".DS_Store"), "different root metadata");
Files.writeString(data.resolve(".DS_Store"), "different nested metadata");
DatapackIngestService.writeOwnership(managed, entry);
assertEquals(expected, ownershipHash(managed));
}
@Test
public void directoryHashRestatsAttributesAndVolumeBeforeOpeningEachFile() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java"));
int method = source.indexOf("private static String directoryHash(File root)");
int entries = source.indexOf("List<Path> entries = new ArrayList<>()", method);
int loop = source.indexOf("for (Path entry : entries)", entries);
int attributes = source.indexOf("BasicFileAttributes attributes = Files.readAttributes(", loop);
int fileStore = source.indexOf("Files.getFileStore(entry)", attributes);
int open = source.indexOf("Files.newInputStream(", fileStore);
int digest = source.indexOf("return hex(digest.digest())", open);
assertTrue(method >= 0);
assertTrue(entries > method);
assertTrue(loop > entries);
assertTrue(attributes > loop);
assertTrue(fileStore > attributes);
assertTrue(open > fileStore);
assertTrue(digest > open);
}
@Test
public void failedUpdateStagingCannotBeAdoptedByTheCommittedManifest() throws Exception {
File staging = datapackDirectory("candidate-staging");
@@ -833,6 +924,205 @@ public class DatapackIngestServiceTest {
assertEquals("old", Files.readString(new File(firstTarget, "value.txt").toPath(), StandardCharsets.UTF_8));
}
@Test
public void exactManagedTargetSkipsPreparedCopyAndScratch() throws Exception {
DatapackIngestService.Entry entry = entry("exact-managed", "v1", "1", "sha");
File staging = datapackDirectory("exact-managed-source");
Files.writeString(new File(staging, "value.txt").toPath(), "same", StandardCharsets.UTF_8);
DatapackIngestService.writeOwnership(staging, entry);
File targetRoot = temporaryFolder.newFolder("exact-managed-target-root");
File worldFolder = new File(targetRoot, "datapacks");
assertTrue(worldFolder.mkdir());
File target = new File(worldFolder, entry.id);
writeManagedDatapack(target, entry, "same");
File scratch = new File(targetRoot, ".iris-datapack-install");
DatapackIngestService.InstallPlan plan = DatapackIngestService.prepareInstall(
staging,
worldFolder,
entry,
ownershipHash(staging),
false,
null
);
assertFalse(plan.publishRequired());
assertFalse(plan.contentChanged());
assertFalse(scratch.exists());
assertTrue(plan.pending() == null || !plan.pending().exists());
}
@Test
public void stagedMutationBeforePrecommitRejectsAndRollsBackPublishedWorlds() throws Exception {
PreparedMixedInstall fixture = preparedMixedInstall("staged-precommit-mutation");
Path stagedValue = new File(fixture.staging(), "value.txt").toPath();
FileTime originalTime = Files.getLastModifiedTime(stagedValue);
Files.writeString(stagedValue, "bad", StandardCharsets.UTF_8);
Files.setLastModifiedTime(stagedValue, originalTime);
try {
DatapackIngestService.verifyInstallExecution(fixture.execution());
fail("Expected changed staging to block the prepared install");
} catch (IOException expected) {
assertTrue(expected.getMessage(), expected.getMessage().contains("staging changed"));
DatapackIngestService.rollbackInstallExecutions(List.of(fixture.execution()), expected);
assertEquals(0, expected.getSuppressed().length);
}
assertEquals("new", Files.readString(
new File(fixture.unchangedTarget(), "value.txt").toPath(), StandardCharsets.UTF_8));
assertEquals("old", Files.readString(
new File(fixture.changedTarget(), "value.txt").toPath(), StandardCharsets.UTF_8));
}
@Test
public void unchangedTargetMutationBeforePrecommitRejectsAndRollsBackPublishedWorlds() throws Exception {
PreparedMixedInstall fixture = preparedMixedInstall("unchanged-precommit-mutation");
Path unchangedValue = new File(fixture.unchangedTarget(), "value.txt").toPath();
FileTime originalTime = Files.getLastModifiedTime(unchangedValue);
Files.writeString(unchangedValue, "bad", StandardCharsets.UTF_8);
Files.setLastModifiedTime(unchangedValue, originalTime);
try {
DatapackIngestService.verifyInstallExecution(fixture.execution());
fail("Expected changed unchanged-target snapshot to block the prepared install");
} catch (IOException expected) {
assertTrue(expected.getMessage(), expected.getMessage().contains("unchanged datapack target"));
DatapackIngestService.rollbackInstallExecutions(List.of(fixture.execution()), expected);
assertEquals(0, expected.getSuppressed().length);
}
assertEquals("bad", Files.readString(unchangedValue, StandardCharsets.UTF_8));
assertEquals("old", Files.readString(
new File(fixture.changedTarget(), "value.txt").toPath(), StandardCharsets.UTF_8));
}
@Test
public void verifiedFreshInstallCommitsAfterExtractedDirectoryIsDeleted() throws Exception {
PreparedVerifiedFreshInstall fixture = preparedVerifiedFreshInstall(
"verified-fresh-deleted-extraction");
DatapackIngestService.deleteInstallScratch(
fixture.extractedDir(), "verified fresh datapack extraction");
assertFalse(fixture.extractedDir().exists());
DatapackIngestService.verifyInstallExecution(fixture.execution());
writeManifest(fixture.root(), fixture.entry());
DatapackIngestService.finishInstallExecution(fixture.execution());
for (File target : List.of(fixture.worldTarget(), fixture.canonicalTarget())) {
assertEquals("new", Files.readString(
new File(target, "value.txt").toPath(), StandardCharsets.UTF_8));
assertTrue(DatapackIngestService.isUsableStaging(target, fixture.entry()));
}
}
@Test
public void publishedTargetMutationRollsBackUnaffectedParticipantsAndRemainsFailClosed() throws Exception {
PreparedVerifiedFreshInstall fixture = preparedVerifiedFreshInstall(
"published-precommit-mutation");
Path publishedValue = new File(fixture.worldTarget(), "value.txt").toPath();
FileTime originalTime = Files.getLastModifiedTime(publishedValue);
Files.writeString(publishedValue, "bad", StandardCharsets.UTF_8);
Files.setLastModifiedTime(publishedValue, originalTime);
try {
DatapackIngestService.verifyInstallExecution(fixture.execution());
fail("Expected changed published target to block the prepared install");
} catch (IOException expected) {
assertTrue(expected.getMessage(), expected.getMessage().contains("published datapack target"));
DatapackIngestService.rollbackInstallExecutions(List.of(fixture.execution()), expected);
assertEquals(1, expected.getSuppressed().length);
}
assertEquals("bad", Files.readString(publishedValue, StandardCharsets.UTF_8));
assertFalse(fixture.canonicalTarget().exists());
File transactionRoot = new File(fixture.root(), ".iris-datapack-transactions");
File[] transactions = transactionRoot.listFiles(File::isDirectory);
assertTrue(transactions != null && transactions.length == 1);
}
@Test
public void changedManagedTargetStillPreparesPublication() throws Exception {
DatapackIngestService.Entry entry = entry("changed-managed", "v1", "1", "sha");
File staging = datapackDirectory("changed-managed-source");
Files.writeString(new File(staging, "value.txt").toPath(), "new", StandardCharsets.UTF_8);
DatapackIngestService.writeOwnership(staging, entry);
File targetRoot = temporaryFolder.newFolder("changed-managed-target-root");
File worldFolder = new File(targetRoot, "datapacks");
assertTrue(worldFolder.mkdir());
writeManagedDatapack(new File(worldFolder, entry.id), entry, "old");
DatapackIngestService.InstallPlan plan = DatapackIngestService.prepareInstall(
staging,
worldFolder,
entry,
ownershipHash(staging),
false,
null
);
assertTrue(plan.publishRequired());
assertTrue(plan.contentChanged());
assertTrue(plan.pending().isDirectory());
assertTrue(plan.pendingRoot().isDirectory());
}
@Test
public void changedOwnershipStillPreparesPublicationForExactContent() throws Exception {
DatapackIngestService.Entry entry = entry("changed-ownership", "v2", "2", "new-sha");
File staging = datapackDirectory("changed-ownership-source");
Files.writeString(new File(staging, "value.txt").toPath(), "same", StandardCharsets.UTF_8);
DatapackIngestService.writeOwnership(staging, entry);
File targetRoot = temporaryFolder.newFolder("changed-ownership-target-root");
File worldFolder = new File(targetRoot, "datapacks");
assertTrue(worldFolder.mkdir());
DatapackIngestService.Entry prior = entry("changed-ownership", "v1", "1", "old-sha");
File target = new File(worldFolder, entry.id);
writeManagedDatapack(target, prior, "same");
DatapackIngestService.InstallPlan plan = DatapackIngestService.prepareInstall(
staging,
worldFolder,
entry,
ownershipHash(staging),
false,
null
);
assertTrue(plan.publishRequired());
assertFalse(plan.contentChanged());
assertTrue(plan.pending().isDirectory());
}
@Test
public void overrideStrippingStillPreparesPublicationForExactContent() throws Exception {
DatapackIngestService.Entry entry = entry("strip-managed", "v1", "1", "sha");
File staging = datapackDirectory("strip-managed-source");
Files.writeString(new File(staging, "value.txt").toPath(), "same", StandardCharsets.UTF_8);
DatapackIngestService.writeOwnership(staging, entry);
File targetRoot = temporaryFolder.newFolder("strip-managed-target-root");
File worldFolder = new File(targetRoot, "datapacks");
assertTrue(worldFolder.mkdir());
writeManagedDatapack(new File(worldFolder, entry.id), entry, "same");
DatapackIngestService.InstallPlan plan = DatapackIngestService.prepareInstall(
staging,
worldFolder,
entry,
ownershipHash(staging),
true,
null
);
assertTrue(plan.publishRequired());
assertTrue(plan.contentChanged());
assertTrue(new File(plan.pending(), ".iris-overrides-stripped").isFile());
}
@Test
public void exactLegacyUnmarkedWorldInstallCanReceiveManagedOwnership() throws Exception {
DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha");
@@ -1101,6 +1391,7 @@ public class DatapackIngestServiceTest {
false,
fixture.root(),
fixture.authorization());
DatapackIngestService.verifyInstallExecution(execution);
writeManifest(fixture.root(), fixture.desired());
DatapackIngestService.finishInstallExecution(execution);
@@ -3060,6 +3351,67 @@ public class DatapackIngestServiceTest {
return new ReapplyFixture(root, stagingRoot, staging, worlds, new File(world, entry.id));
}
private PreparedMixedInstall preparedMixedInstall(String name) throws Exception {
File root = temporaryFolder.newFolder(name + "-root").toPath().toRealPath().toFile();
DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha");
writeManifest(root, entry);
File staging = new File(root, "staging/" + entry.id);
writeManagedDatapack(staging, entry, "new");
File unchangedRoot = temporaryFolder.newFolder(name + "-unchanged-root");
File unchangedWorld = new File(unchangedRoot, "datapacks");
assertTrue(unchangedWorld.mkdir());
File unchangedTarget = new File(unchangedWorld, entry.id);
writeManagedDatapack(unchangedTarget, entry, "new");
File changedRoot = temporaryFolder.newFolder(name + "-changed-root");
File changedWorld = new File(changedRoot, "datapacks");
assertTrue(changedWorld.mkdir());
File changedTarget = new File(changedWorld, entry.id);
writeManagedDatapack(changedTarget, entry, "old");
KList<File> worlds = new KList<>();
worlds.add(unchangedWorld);
worlds.add(changedWorld);
DatapackIngestService.InstallExecution execution =
DatapackIngestService.prepareInstallExecution(staging, worlds, entry, false, root);
assertTrue(execution.result().changed());
assertEquals("new", Files.readString(
new File(changedTarget, "value.txt").toPath(), StandardCharsets.UTF_8));
return new PreparedMixedInstall(staging, unchangedTarget, changedTarget, execution);
}
private PreparedVerifiedFreshInstall preparedVerifiedFreshInstall(String name) throws Exception {
LegacyStagingFixture fixture = legacyStagingFixture(name, false, true, false);
File world = temporaryFolder.newFolder(name + "-world");
KList<File> worlds = new KList<>();
worlds.add(world);
DatapackIngestService.InstallExecution execution =
DatapackIngestService.prepareInstallExecution(
fixture.source(),
worlds,
fixture.desired(),
false,
fixture.root(),
fixture.authorization());
File worldTarget = new File(world, fixture.desired().id);
assertTrue(fixture.source().isDirectory());
assertEquals("new", Files.readString(
new File(worldTarget, "value.txt").toPath(), StandardCharsets.UTF_8));
assertEquals("new", Files.readString(
new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8));
return new PreparedVerifiedFreshInstall(
fixture.root(),
fixture.source(),
fixture.desired(),
fixture.target(),
worldTarget,
execution
);
}
private JsonObject manifestEntry(File root) throws Exception {
JsonObject manifest = JsonParser.parseString(Files.readString(
new File(root, "manifest.json").toPath(), StandardCharsets.UTF_8)).getAsJsonObject();
@@ -3075,6 +3427,24 @@ public class DatapackIngestServiceTest {
) {
}
private record PreparedMixedInstall(
File staging,
File unchangedTarget,
File changedTarget,
DatapackIngestService.InstallExecution execution
) {
}
private record PreparedVerifiedFreshInstall(
File root,
File extractedDir,
DatapackIngestService.Entry entry,
File canonicalTarget,
File worldTarget,
DatapackIngestService.InstallExecution execution
) {
}
private LegacyStagingFixture legacyStagingFixture(
String name,
boolean committed,
@@ -0,0 +1,99 @@
package art.arcane.iris.core.nms;
import org.junit.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ServerShutdownBoundaryTest {
@Test
public void await_returnsImmediatelyWhenBoundaryIsAlreadyReached() {
assertTrue(ServerShutdownBoundary.await(
() -> true,
Thread.currentThread(),
0L,
TimeUnit.MILLISECONDS
));
}
@Test
public void await_doesNotJoinTheCallingServerThread() {
assertFalse(ServerShutdownBoundary.await(
() -> false,
Thread.currentThread(),
5L,
TimeUnit.SECONDS
));
}
@Test
public void await_blocksUntilAuthoritativeBoundaryIsReached() throws Exception {
CountDownLatch serverStarted = new CountDownLatch(1);
CountDownLatch releaseServer = new CountDownLatch(1);
CountDownLatch waiterStarted = new CountDownLatch(1);
CountDownLatch waiterFinished = new CountDownLatch(1);
AtomicBoolean boundaryReached = new AtomicBoolean(false);
AtomicReference<Boolean> result = new AtomicReference<>(false);
Thread serverThread = new Thread(() -> {
serverStarted.countDown();
await(releaseServer);
boundaryReached.set(true);
}, "server-boundary-test");
Thread waiterThread = new Thread(() -> {
waiterStarted.countDown();
result.set(ServerShutdownBoundary.await(
boundaryReached::get,
serverThread,
5L,
TimeUnit.SECONDS
));
waiterFinished.countDown();
}, "server-boundary-waiter-test");
serverThread.start();
assertTrue(serverStarted.await(1L, TimeUnit.SECONDS));
waiterThread.start();
assertTrue(waiterStarted.await(1L, TimeUnit.SECONDS));
assertFalse(waiterFinished.await(0L, TimeUnit.MILLISECONDS));
releaseServer.countDown();
assertTrue(waiterFinished.await(2L, TimeUnit.SECONDS));
assertTrue(result.get());
serverThread.join();
waiterThread.join();
}
@Test
public void await_returnsFalseWhenBoundaryDoesNotArriveBeforeTimeout() throws Exception {
CountDownLatch releaseServer = new CountDownLatch(1);
Thread serverThread = new Thread(() -> await(releaseServer), "server-boundary-timeout-test");
serverThread.start();
try {
assertFalse(ServerShutdownBoundary.await(
() -> false,
serverThread,
0L,
TimeUnit.MILLISECONDS
));
} finally {
releaseServer.countDown();
serverThread.join();
}
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
}
@@ -29,9 +29,19 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class PackValidationRegistryTest {
@@ -120,6 +130,98 @@ public class PackValidationRegistryTest {
assertEquals(result, PackValidationRegistry.requireLoadable(realRoot));
}
@Test
public void copiedValidationPublishesOnlyForTheExactValidatedFingerprint() throws Exception {
Path sourceRoot = temporaryFolder.newFolder("copy-source").toPath();
Path matchingTarget = temporaryFolder.newFolder("copy-matching-target").toPath();
Path mismatchedTarget = temporaryFolder.newFolder("copy-mismatched-target").toPath();
PackValidationResult result = new PackValidationResult(
"source", List.of(), List.of("source warning"), 7L);
PackValidationRegistry.publish(sourceRoot, result, "fingerprint-a");
assertSame(result, PackValidationRegistry.publishMatchingCopy(
sourceRoot,
matchingTarget,
"fingerprint-a"));
assertSame(result, PackValidationRegistry.requireLoadable(matchingTarget));
assertNull(PackValidationRegistry.publishMatchingCopy(
sourceRoot,
mismatchedTarget,
"fingerprint-b"));
assertNull(PackValidationRegistry.get(mismatchedTarget));
}
@Test
public void unfingerprintedRepublishRevokesCopiedValidationReuse() throws Exception {
Path sourceRoot = temporaryFolder.newFolder("republished-source").toPath();
Path targetRoot = temporaryFolder.newFolder("republished-target").toPath();
PackValidationResult initial = new PackValidationResult("source", List.of(), List.of(), 3L);
PackValidationResult replacement = new PackValidationResult("source", List.of(), List.of(), 5L);
PackValidationRegistry.publish(sourceRoot, initial, "old-fingerprint");
PackValidationRegistry.publish(sourceRoot, replacement);
assertNull(PackValidationRegistry.publishMatchingCopy(
sourceRoot,
targetRoot,
"old-fingerprint"));
assertSame(replacement, PackValidationRegistry.requireLoadable(sourceRoot));
assertNull(PackValidationRegistry.get(targetRoot));
}
@Test
public void rootMutationDefeatsAnInterleavedStaleValidationTicket() throws Exception {
Path packRoot = temporaryFolder.newFolder("reserved-root").toPath();
PackValidationResult original = new PackValidationResult(
"pack", List.of(), List.of("original"), 1L);
PackValidationResult stale = new PackValidationResult(
"pack", List.of(), List.of("stale"), 2L);
PackValidationResult replacement = new PackValidationResult(
"pack", List.of(), List.of("replacement"), 3L);
PackValidationRegistry.publish(packRoot, original);
CountDownLatch ticketReady = new CountDownLatch(1);
CountDownLatch mutationStarted = new CountDownLatch(1);
AtomicReference<PackValidationRegistry.ValidationTicket> ticket = new AtomicReference<>();
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Boolean> stalePublish = executor.submit(() -> {
PackValidationRegistry.ValidationTicket validationTicket =
PackValidationRegistry.tryBeginValidation(packRoot);
ticket.set(validationTicket);
ticketReady.countDown();
if (!mutationStarted.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("Root mutation did not begin");
}
return PackValidationRegistry.publishIfCurrent(validationTicket, stale);
});
try {
assertTrue(ticketReady.await(5, TimeUnit.SECONDS));
assertNotNull(ticket.get());
try (PackValidationRegistry.RootMutation mutation =
PackValidationRegistry.beginRootMutation(packRoot)) {
assertNull(PackValidationRegistry.get(packRoot));
assertThrows(BrokenPackException.class,
() -> PackValidationRegistry.requireLoadable(packRoot));
assertNull(PackValidationRegistry.tryBeginValidation(packRoot));
mutationStarted.countDown();
assertFalse(stalePublish.get(5, TimeUnit.SECONDS));
assertNull(PackValidationRegistry.get(packRoot));
assertNull(PackValidationRegistry.tryBeginValidation(packRoot));
mutation.stage(replacement);
assertNull(PackValidationRegistry.get(packRoot));
mutation.commit();
}
assertSame(replacement, PackValidationRegistry.requireLoadable(packRoot));
} finally {
mutationStarted.countDown();
executor.shutdownNow();
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
}
}
private void assertBroken(String pack, String expectedReason) {
try {
PackValidationRegistry.requireLoadable(pack);
@@ -132,11 +234,12 @@ public class PackValidationRegistryTest {
throw new AssertionError("Expected pack validation to fail closed");
}
private void assertBroken(Path packRoot, String expectedReason) {
private void assertBroken(Path packRoot, String expectedReason) throws IOException {
try {
PackValidationRegistry.requireLoadable(packRoot);
} catch (BrokenPackException e) {
assertEquals(packRoot.toAbsolutePath().normalize().toString(), e.getPackName());
Path expectedRoot = packRoot.getParent().toRealPath().resolve(packRoot.getFileName()).normalize();
assertEquals(expectedRoot.toString(), e.getPackName());
assertTrue(e.getReasons().toString(), e.getReasons().stream().anyMatch(
reason -> reason.contains(expectedReason)));
return;
@@ -30,6 +30,7 @@ import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.platform.bukkit.BukkitBlockState;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
@@ -37,6 +38,7 @@ import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.util.common.data.B;
import art.arcane.iris.util.common.math.IrisBlockVector;
import art.arcane.iris.util.common.plugin.VolmitPlugin;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
@@ -114,6 +116,35 @@ import static org.mockito.Mockito.when;
public class JigsawStudioServiceCaptureTest {
@Test
public void shutdownQuiesceIsIdempotentAndResetsOnEnable() throws Exception {
JigsawStudioService service = new JigsawStudioService();
VolmitPlugin plugin = mock(VolmitPlugin.class);
Field disableStartedField = JigsawStudioService.class.getDeclaredField("disableStarted");
Field enabledField = JigsawStudioService.class.getDeclaredField("enabled");
disableStartedField.setAccessible(true);
enabledField.setAccessible(true);
AtomicBoolean disableStarted = (AtomicBoolean) disableStartedField.get(service);
try (MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class)) {
platform.when(BukkitPlatform::volmitPlugin).thenReturn(plugin);
service.onEnable();
assertFalse(disableStarted.get());
assertTrue(enabledField.getBoolean(service));
service.quiesceForServerShutdown();
service.quiesceForServerShutdown();
assertTrue(disableStarted.get());
assertFalse(enabledField.getBoolean(service));
service.onEnable();
assertFalse(disableStarted.get());
assertTrue(enabledField.getBoolean(service));
service.onDisable();
}
}
@Test
public void successfulStudioSavePlaysOneOwnerLocalBell() {
Player player = mock(Player.class);
@@ -1,5 +1,6 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.BrokenPackException;
@@ -43,6 +44,12 @@ public class StudioSVCWorldPackPublishTest {
Path target = root.resolve("iris/pack");
Files.createDirectories(source.resolve("dimensions"));
Files.writeString(source.resolve("dimensions/example.json"), "{}");
Files.writeString(source.resolve("dimensions/.hidden.json"), "{}");
Files.createDirectories(source.resolve(".iris/schema"));
Files.createDirectories(source.resolve(".git/objects"));
Files.writeString(source.resolve(".iris/schema/generated.json"), "{}");
Files.writeString(source.resolve(".git/objects/blob"), "metadata");
Files.writeString(source.resolve("source.code-workspace"), "{}");
Files.createDirectories(stage);
StudioSVC.copyPackTree(source, stage);
@@ -51,6 +58,10 @@ public class StudioSVCWorldPackPublishTest {
assertFalse(Files.exists(stage));
assertTrue(Files.isRegularFile(target.resolve("dimensions/example.json")));
assertTrue(Files.isRegularFile(target.resolve("dimensions/.hidden.json")));
assertFalse(Files.exists(target.resolve(".iris")));
assertFalse(Files.exists(target.resolve(".git")));
assertFalse(Files.exists(target.resolve("source.code-workspace")));
}
@Test
@@ -111,6 +122,93 @@ public class StudioSVCWorldPackPublishTest {
assertTrue(Files.isRegularFile(stage.resolve("dimensions/example.json")));
}
@Test
public void copyRejectsAnInstallationStageInsideTheSource() throws IOException {
Path source = temporaryFolder.newFolder("overlapping-copy").toPath();
Path stage = source.resolve("nested-stage");
Files.writeString(source.resolve("pack.txt"), "source");
IOException failure = assertThrows(
IOException.class,
() -> StudioSVC.copyPackTree(source, stage));
assertTrue(failure.getMessage().contains("overlap"));
assertFalse(Files.exists(stage));
assertEquals("source", Files.readString(source.resolve("pack.txt")));
}
@Test
public void copyRejectsASymbolicInstallationStage() throws IOException {
Path root = temporaryFolder.newFolder("linked-copy-stage").toPath();
Path source = root.resolve("source");
Path outside = root.resolve("outside");
Path stage = root.resolve("stage");
Files.createDirectories(source);
Files.createDirectories(outside);
Files.writeString(source.resolve("pack.txt"), "source");
try {
Files.createSymbolicLink(stage, outside);
} catch (IOException | UnsupportedOperationException exception) {
Assume.assumeNoException(exception);
}
IOException failure = assertThrows(
IOException.class,
() -> StudioSVC.copyPackTree(source, stage));
assertTrue(failure.getMessage().contains("symbolic link"));
assertFalse(Files.exists(outside.resolve("pack.txt")));
}
@Test
public void replaceExistingRejectsSymbolicTargetBeforePublication() throws Exception {
String sourceCode = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/service/StudioSVC.java"));
int install = sourceCode.indexOf("private IrisDimension installIntoDirectory(");
int initialTargetSafety = sourceCode.indexOf(
"requireSafePublicationTarget(target, replaceExisting)",
install);
int beginMutation = sourceCode.indexOf(
"PackValidationRegistry.beginRootMutation(target)",
initialTargetSafety);
int finalTargetSafety = sourceCode.indexOf(
"requireSafePublicationTarget(target, replaceExisting)",
beginMutation);
int publish = sourceCode.indexOf(
"AtomicDirectoryPublisher.publish(stage, target)",
finalTargetSafety);
assertTrue(install >= 0);
assertTrue(initialTargetSafety > install);
assertTrue(beginMutation > initialTargetSafety);
assertTrue(finalTargetSafety > beginMutation);
assertTrue(publish > finalTargetSafety);
Path root = temporaryFolder.newFolder("symbolic-replacement-target").toPath();
Path outside = root.resolve("outside-pack");
Path target = root.resolve("world/iris/pack");
Files.createDirectories(outside);
Files.createDirectories(target.getParent());
Files.writeString(outside.resolve("sentinel.txt"), "unchanged");
try {
Files.createSymbolicLink(target, outside);
} catch (IOException | UnsupportedOperationException | SecurityException exception) {
Assume.assumeNoException(exception);
}
PackValidationResult existingValidation = new PackValidationResult(
"pack", List.of(), List.of("existing target"), 29L);
PackValidationRegistry.publish(target, existingValidation);
IOException failure = assertThrows(
IOException.class,
() -> StudioSVC.requireSafePublicationTarget(target, true));
assertTrue(failure.getMessage().contains("symbolic link"));
assertTrue(Files.isSymbolicLink(target));
assertEquals(outside.toRealPath(), target.toRealPath());
assertEquals("unchanged", Files.readString(outside.resolve("sentinel.txt")));
assertSame(existingValidation, PackValidationRegistry.requireLoadable(target));
}
@Test
public void rejectedPublicationEvictsCreatedLoaderBeforeDiskRollback() throws IOException {
Path root = temporaryFolder.newFolder("cache-rollback").toPath();
@@ -149,6 +247,120 @@ public class StudioSVCWorldPackPublishTest {
assertTrue(PackValidationRegistry.isBroken(packRoot));
}
@Test
public void exactCopiedFingerprintReusesSourceSemanticValidation() throws Exception {
Path root = temporaryFolder.newFolder("matching-validation-copy").toPath();
Path source = root.resolve("source");
Path target = root.resolve("target");
writeValidPack(source);
StudioSVC.copyPackTree(source, target);
String sourceFingerprint = ServerConfigurator.computePackTreeFingerprint(source.toFile());
String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile());
PackValidationResult sourceValidation = new PackValidationResult(
"source", List.of(), List.of("preserved source warning"), 17L);
PackValidationRegistry.publish(source, sourceValidation, sourceFingerprint);
PackValidationResult reused = StudioSVC.validatePublishedPack(
target,
source,
copiedFingerprint);
assertEquals(sourceFingerprint, copiedFingerprint);
assertSame(sourceValidation, reused);
assertSame(sourceValidation, PackValidationRegistry.requireLoadable(target));
}
@Test
public void copiedFingerprintMismatchFallsBackToTargetValidation() throws Exception {
Path root = temporaryFolder.newFolder("mismatched-validation-copy").toPath();
Path source = root.resolve("source");
Path target = root.resolve("target");
writeValidPack(source);
StudioSVC.copyPackTree(source, target);
String sourceFingerprint = ServerConfigurator.computePackTreeFingerprint(source.toFile());
PackValidationResult sourceValidation = new PackValidationResult(
"source", List.of(), List.of(), 19L);
PackValidationRegistry.publish(source, sourceValidation, sourceFingerprint);
Files.writeString(target.resolve("dimensions/main.json"), "{");
String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile());
assertFalse(sourceFingerprint.equals(copiedFingerprint));
assertThrows(BrokenPackException.class, () -> StudioSVC.validatePublishedPack(
target,
source,
copiedFingerprint));
assertTrue(PackValidationRegistry.isBroken(target));
assertSame(sourceValidation, PackValidationRegistry.requireLoadable(source));
}
@Test
public void replacementKeepsTargetUnauthorizedThroughPublishedFingerprintWindow() throws Exception {
String sourceCode = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/service/StudioSVC.java"));
int install = sourceCode.indexOf("private IrisDimension installIntoDirectory(");
int beginMutation = sourceCode.indexOf("PackValidationRegistry.beginRootMutation(target)", install);
int publish = sourceCode.indexOf("AtomicDirectoryPublisher.publish(stage, target)", beginMutation);
int fingerprint = sourceCode.indexOf(
"ServerConfigurator.computePackTreeFingerprint(target.toFile())",
publish);
int stageValidation = sourceCode.indexOf(
"validatePublishedPack(target, source, copiedFingerprint, validationMutation)",
fingerprint);
int publishCommit = sourceCode.indexOf("publication.commit()", stageValidation);
int validationCommit = sourceCode.indexOf("validationMutation.commit()", publishCommit);
assertTrue(install >= 0);
assertTrue(beginMutation > install);
assertTrue(publish > beginMutation);
assertTrue(fingerprint > publish);
assertTrue(stageValidation > fingerprint);
assertTrue(publishCommit > stageValidation);
assertTrue(validationCommit > publishCommit);
Path root = temporaryFolder.newFolder("validation-publication-window").toPath();
Path sourcePack = root.resolve("source");
Path target = root.resolve("world/iris/pack");
Path stage = root.resolve("world/iris/.pack.installing-test");
writeValidPack(sourcePack);
writeValidPack(target);
StudioSVC.copyPackTree(sourcePack, stage);
String sourceFingerprint = ServerConfigurator.computePackTreeFingerprint(sourcePack.toFile());
PackValidationResult sourceValidation = new PackValidationResult(
"source", List.of(), List.of(), 23L);
PackValidationResult staleTargetValidation = new PackValidationResult(
"pack", List.of(), List.of("stale target"), 11L);
PackValidationRegistry.publish(sourcePack, sourceValidation, sourceFingerprint);
PackValidationRegistry.publish(target, staleTargetValidation);
assertSame(staleTargetValidation, PackValidationRegistry.requireLoadable(target));
AtomicDirectoryPublisher.Publication publication = null;
try (PackValidationRegistry.RootMutation validationMutation =
PackValidationRegistry.beginRootMutation(target)) {
assertNull(PackValidationRegistry.get(target));
assertThrows(BrokenPackException.class, () -> PackValidationRegistry.requireLoadable(target));
publication = AtomicDirectoryPublisher.publish(stage, target);
assertNull(PackValidationRegistry.get(target));
String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile());
assertNull(PackValidationRegistry.get(target));
assertThrows(BrokenPackException.class, () -> PackValidationRegistry.requireLoadable(target));
PackValidationResult staged = validationMutation.stageMatchingCopy(
sourcePack,
copiedFingerprint);
assertSame(sourceValidation, staged);
assertNull(PackValidationRegistry.get(target));
publication.commit();
assertNull(PackValidationRegistry.get(target));
validationMutation.commit();
assertSame(sourceValidation, PackValidationRegistry.requireLoadable(target));
publication.cleanupBackup();
} finally {
if (publication != null) {
publication.close();
}
}
}
@Test
public void createdProjectRollbackEvictsOnlyItsCachedLoaderBeforeDeletion() throws IOException {
Path root = temporaryFolder.newFolder("project-cache-rollback").toPath();
@@ -1,5 +1,6 @@
package art.arcane.iris.core.tools;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
import art.arcane.iris.util.common.plugin.VolmitSender;
import org.bukkit.Chunk;
@@ -8,10 +9,14 @@ import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.InOrder;
import org.mockito.MockedStatic;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
@@ -20,6 +25,7 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class IrisCreatorTeleportTest {
@Test
@@ -99,4 +105,72 @@ public class IrisCreatorTeleportTest {
assertThrows(CompletionException.class, result::join);
}
@Test
public void awaitTeleportFailure_returnsNullForSuccessfulTeleport() {
CompletableFuture<Boolean> teleport = CompletableFuture.completedFuture(true);
Throwable failure = IrisCreator.awaitTeleportFailure(
teleport,
"ParthOP69",
1L,
TimeUnit.SECONDS
);
assertNull(failure);
assertFalse(teleport.isCancelled());
}
@Test
public void awaitTeleportFailure_reportsFalseTeleportResult() {
CompletableFuture<Boolean> teleport = CompletableFuture.completedFuture(false);
Throwable failure = IrisCreator.awaitTeleportFailure(
teleport,
"ParthOP69",
1L,
TimeUnit.SECONDS
);
assertTrue(failure instanceof IllegalStateException);
assertEquals(
"The runtime teleport operation returned false for player \"ParthOP69\".",
failure.getMessage()
);
assertFalse(teleport.isCancelled());
}
@Test
public void awaitTeleportFailure_unwrapsExceptionalTeleportResult() {
IllegalStateException expected = new IllegalStateException("teleport failed");
CompletableFuture<Boolean> teleport = CompletableFuture.failedFuture(expected);
Throwable failure = IrisCreator.awaitTeleportFailure(
teleport,
"ParthOP69",
1L,
TimeUnit.SECONDS
);
assertSame(expected, failure);
assertFalse(teleport.isCancelled());
}
@Test
public void awaitTeleportFailure_cancelsTimedOutTeleportWithoutRestartingServer() {
CompletableFuture<Boolean> teleport = new CompletableFuture<>();
try (MockedStatic<ServerConfigurator> serverConfigurator = mockStatic(ServerConfigurator.class)) {
Throwable failure = IrisCreator.awaitTeleportFailure(
teleport,
"ParthOP69",
0L,
TimeUnit.MILLISECONDS
);
assertTrue(failure instanceof TimeoutException);
assertTrue(teleport.isCancelled());
serverConfigurator.verifyNoInteractions();
}
}
}
@@ -380,6 +380,40 @@ public class BukkitChunkGeneratorGenerationStageGateTest {
}
}
@Test
public void queuedStageRemainsAdmittedWhileShutdownIsOnlyQuiesced() throws Exception {
AtomicBoolean closing = new AtomicBoolean(false);
BukkitChunkGenerator.GenerationStageGate gate =
new BukkitChunkGenerator.GenerationStageGate(1, closing::get);
gate.acquireExclusive();
boolean exclusiveHeld = true;
BukkitChunkGenerator.GenerationStagePermit admitted = null;
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<BukkitChunkGenerator.GenerationStagePermit> stage =
executor.submit(() -> gate.acquireStage("paper-queued-before-shutdown-boundary"));
awaitQueueLength(gate, 1);
assertFalse(closing.get());
gate.releaseExclusive();
exclusiveHeld = false;
admitted = stage.get(2, TimeUnit.SECONDS);
assertEquals(0, gate.availablePermits());
admitted.close();
assertEquals(1, gate.availablePermits());
} finally {
if (admitted != null) {
admitted.close();
}
if (exclusiveHeld) {
gate.releaseExclusive();
}
executor.shutdownNow();
}
}
@Test
public void queuedStageIsRejectedAfterCloseBegins() throws Exception {
AtomicBoolean closing = new AtomicBoolean(false);