mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
Modded adapters
This commit is contained in:
+12
@@ -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<>();
|
||||
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
boolean serverStopping = IrisToolbelt.isServerStopping();
|
||||
if (serverStopping) {
|
||||
quiesceRuntimeForServerShutdown("onDisable");
|
||||
startPostStopFinisher();
|
||||
} else {
|
||||
teardownRuntime("onDisable", 30L);
|
||||
removeShutdownHook();
|
||||
J.attempt(() -> INMS.get().uninjectBukkit());
|
||||
}
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
if (IrisToolbelt.access(world) != null) {
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
if (generator != null) {
|
||||
irisWorlds.add(world);
|
||||
generators.add(generator);
|
||||
}
|
||||
}
|
||||
if (irisWorlds.isEmpty()) {
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
+97
-23
@@ -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()));
|
||||
}
|
||||
}
|
||||
try {
|
||||
PackValidationCache.save(cacheFile, contentFingerprint, contextFingerprint, results);
|
||||
PackValidationCache.save(
|
||||
cacheFile,
|
||||
contentSnapshot.content(),
|
||||
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,6 +203,9 @@ public final class IrisWorldGeneratorResolver {
|
||||
synchronized (SNAPSHOT_VALIDATION_LOCK) {
|
||||
result = PackValidationRegistry.get(normalizedRoot);
|
||||
if (result == null) {
|
||||
PackValidationRegistry.ValidationTicket ticket =
|
||||
PackValidationRegistry.tryBeginValidation(normalizedRoot);
|
||||
if (ticket != null) {
|
||||
try {
|
||||
result = PackValidator.validate(normalizedRoot.toFile());
|
||||
} catch (Throwable exception) {
|
||||
@@ -156,7 +221,8 @@ public final class IrisWorldGeneratorResolver {
|
||||
List.of(),
|
||||
System.currentTimeMillis());
|
||||
}
|
||||
PackValidationRegistry.publish(normalizedRoot, result);
|
||||
PackValidationRegistry.publishIfCurrent(ticket, 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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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(
|
||||
|
||||
+1
@@ -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()"));
|
||||
|
||||
|
||||
@@ -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 = 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,8 +155,10 @@ public final class DatapackIngestService {
|
||||
Path cacheFile = new File(root, STARTUP_VALIDATION_CACHE).toPath();
|
||||
|
||||
try {
|
||||
String localFingerprint = startupValidationFingerprint(root, worldFolders);
|
||||
StartupValidationCache cached = readStartupValidationCache(cacheFile);
|
||||
if (startupValidationContextMatches(
|
||||
cached, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) {
|
||||
String localFingerprint = startupValidationFingerprint(root, worldFolders);
|
||||
if (startupValidationCacheMatches(
|
||||
cached,
|
||||
mcVersion,
|
||||
@@ -167,6 +172,7 @@ public final class DatapackIngestService {
|
||||
IrisStartupValidation.markDatapacksReady();
|
||||
return StartupValidationOutcome.READY;
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
IrisLogging.warn("Persisted external datapack validation could not be reused: "
|
||||
+ failureMessage(exception));
|
||||
@@ -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);
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
for (Path entry : entries) {
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
entry, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
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) {
|
||||
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() {
|
||||
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) {
|
||||
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();
|
||||
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())) {
|
||||
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())) {
|
||||
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,12 +302,57 @@ 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);
|
||||
try (PackValidationRegistry.RootMutation mutation =
|
||||
PackValidationRegistry.beginRootMutation(packRoot)) {
|
||||
PackValidationResult result = PackValidator.validate(packRoot.toFile());
|
||||
PackValidationRegistry.publish(packRoot, result);
|
||||
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(
|
||||
IrisData createdData,
|
||||
@@ -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 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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() + "\"."));
|
||||
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 = 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");
|
||||
|
||||
+105
-3
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user