From ef2e1b8f586bd7aa27d9b0658aa9bad15e1179bc Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sat, 15 Aug 2026 15:58:26 -0400 Subject: [PATCH] Modded adapters --- .../iris/core/nms/v26_2_R1/NMSBinding.java | 12 + ...MSBindingShutdownBoundaryContractTest.java | 28 + adapters/bukkit/plugin/build.gradle | 2 + .../src/main/java/art/arcane/iris/Iris.java | 178 ++++++- .../iris/core/IrisWorldGeneratorResolver.java | 148 ++++-- .../arcane/iris/IrisShutdownOrderingTest.java | 166 ++++++ .../core/IrisWorldGeneratorResolverTest.java | 17 + .../BukkitEngineLifecycleContractTest.java | 1 + adapters/fabric/logs/latest.log | 491 +----------------- .../iris/core/IrisDatapackCompiler.java | 70 ++- .../arcane/iris/core/ServerConfigurator.java | 165 +++++- .../core/datapack/DatapackIngestService.java | 423 ++++++++++++--- .../art/arcane/iris/core/nms/INMSBinding.java | 5 + .../iris/core/nms/ServerShutdownBoundary.java | 59 +++ .../core/pack/PackValidationRegistry.java | 233 ++++++++- .../core/service/JigsawStudioService.java | 61 ++- .../arcane/iris/core/service/StudioSVC.java | 177 +++++-- .../arcane/iris/core/tools/IrisCreator.java | 42 +- .../engine/platform/BukkitChunkGenerator.java | 9 + .../platform/PlatformChunkGenerator.java | 3 + ...sDatapackCompilerInputFingerprintTest.java | 32 ++ ...erConfiguratorDatapackFingerprintTest.java | 108 +++- .../datapack/DatapackIngestServiceTest.java | 370 +++++++++++++ .../core/nms/ServerShutdownBoundaryTest.java | 99 ++++ .../core/pack/PackValidationRegistryTest.java | 107 +++- .../JigsawStudioServiceCaptureTest.java | 31 ++ .../StudioSVCWorldPackPublishTest.java | 212 ++++++++ .../core/tools/IrisCreatorTeleportTest.java | 74 +++ ...ChunkGeneratorGenerationStageGateTest.java | 34 ++ 29 files changed, 2647 insertions(+), 710 deletions(-) create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingShutdownBoundaryContractTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/nms/ServerShutdownBoundary.java create mode 100644 core/src/test/java/art/arcane/iris/core/nms/ServerShutdownBoundaryTest.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index 7b2837e52..d7315d8cb 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -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> getBlockProperties() { KMap> states = new KMap<>(); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingShutdownBoundaryContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingShutdownBoundaryContractTest.java new file mode 100644 index 000000000..7dd77b89d --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingShutdownBoundaryContractTest.java @@ -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> 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); + } +} diff --git a/adapters/bukkit/plugin/build.gradle b/adapters/bukkit/plugin/build.gradle index 8b71a70c4..dffa5da6a 100644 --- a/adapters/bukkit/plugin/build.gradle +++ b/adapters/bukkit/plugin/build.gradle @@ -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) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 5d2fd6503..7778a0b19 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -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 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 enabledServices = new CopyOnWriteArrayList<>(); + private final List deferredShutdownGenerators = new CopyOnWriteArrayList<>(); private final IrisWorldGeneratorResolver generatorResolver = new IrisWorldGeneratorResolver(this); private final BukkitWorldReconciler worldReconciler = new BukkitWorldReconciler(this); private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this); private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this); private volatile SettingsHotloadWatch settingsHotloadWatch; + private volatile Thread serverLifecycleThread; public static VolmitSender getSender() { if (sender == null) { @@ -530,8 +536,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return false; } alreadyDrained.set(false); + postStopFinisherStarted.set(false); + serverStopTeardownDeferred.set(false); servicesDisabled.set(false); sharedRuntimeClosed.set(false); + terminalCleanupCompleted.set(false); + deferredShutdownGenerators.clear(); MultiBurst.burst.reopen(); MultiBurst.ioBurst.reopen(); IrisLanguage.initialize(); @@ -669,7 +679,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public void addShutdownHook() { removeShutdownHook(); - shutdownHook = new Thread(() -> teardownRuntime("shutdown-hook", 30L), "Iris-ShutdownHook"); + serverLifecycleThread = Thread.currentThread(); + shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook"); try { Runtime.getRuntime().addShutdownHook(shutdownHook); } catch (IllegalStateException ex) { @@ -756,9 +767,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public void onDisable() { teardownPapi(); - teardownRuntime("onDisable", 30L); - removeShutdownHook(); - J.attempt(() -> INMS.get().uninjectBukkit()); + boolean serverStopping = IrisToolbelt.isServerStopping(); + if (serverStopping) { + quiesceRuntimeForServerShutdown("onDisable"); + startPostStopFinisher(); + } else { + teardownRuntime("onDisable", 30L); + removeShutdownHook(); + } if (BukkitPlatform.hasHud()) { BukkitPlatform.hudSlots().shutdown(); BukkitPlatform.hudLanes().shutdown(); @@ -767,15 +783,22 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { configHotloadEngine.clear(); configHotloadEngine = null; } - runPostShutdown(); // super.onDisable() cancels plugin tasks and unregisters every listener. super.onDisable(); - IrisPlatforms.unbind(); + if (!serverStopping) { + finishTerminalCleanup(); + } } @Override public void onPreUnload(ReloadAware.PreUnloadReason reason) { teardownPapi(); + if (IrisToolbelt.isServerStopping()) { + quiesceRuntimeForServerShutdown("pre-unload:" + reason); + startPostStopFinisher(); + Iris.info("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers."); + return; + } if (alreadyDrained.get()) { Iris.info("Pre-unload hook skipped; Iris already drained."); return; @@ -829,14 +852,131 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } } - private void drainWorldGenerators(String reason, long timeoutSeconds) { - List irisWorlds = new ArrayList<>(); - for (World world : Bukkit.getWorlds()) { - if (IrisToolbelt.access(world) != null) { - irisWorlds.add(world); + private void quiesceRuntimeForServerShutdown(String reason) { + serverStopTeardownDeferred.set(true); + JigsawStudioService jigsawStudioService = IrisServices.getOrNull(JigsawStudioService.class); + if (jigsawStudioService != null) { + try { + jigsawStudioService.quiesceForServerShutdown(); + } catch (Throwable e) { + Iris.reportError("Failed to quiesce Jigsaw Studio before server shutdown.", e); } } - if (irisWorlds.isEmpty()) { + StudioSVC studioService = IrisServices.getOrNull(StudioSVC.class); + if (studioService != null) { + studioService.quiesceDownloadsForShutdown(); + } + + try { + PregeneratorJob.shutdownAndWait(SERVER_STOP_PREGEN_TIMEOUT_MILLIS); + } catch (Throwable e) { + Iris.reportError("Failed to quiesce the Iris pregenerator before server shutdown.", e); + } + + for (World world : Bukkit.getWorlds()) { + PlatformChunkGenerator generator = IrisToolbelt.access(world); + if (generator == null) { + continue; + } + IrisToolbelt.beginWorldMaintenance(world, reason, true); + if (!deferredShutdownGenerators.contains(generator)) { + deferredShutdownGenerators.add(generator); + } + generator.quiesceForServerShutdown(); + } + } + + private void startPostStopFinisher() { + if (!postStopFinisherStarted.compareAndSet(false, true)) { + return; + } + Thread activeServerThread = serverLifecycleThread; + if (activeServerThread == null) { + Iris.warn("Iris could not start its post-stop runtime finisher because the server lifecycle thread is unavailable."); + return; + } + + Thread finisher = new Thread(() -> { + if (!awaitServerThreadTermination(activeServerThread)) { + return; + } + finishDeferredRuntimeTeardown("post-server-stop", 30L); + }, "Iris-PostStop-Finisher"); + finisher.setDaemon(false); + finisher.start(); + } + + static boolean awaitServerThreadTermination(Thread serverThread) { + if (serverThread == null || serverThread == Thread.currentThread()) { + return false; + } + try { + serverThread.join(); + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Iris.reportError("Iris post-stop runtime finisher was interrupted.", e); + return false; + } + } + + private void runShutdownHook() { + if (!awaitServerShutdownBoundary()) { + Iris.warn("Iris skipped JVM-hook runtime teardown because Paper did not reach its post-world-close boundary."); + return; + } + finishDeferredRuntimeTeardown("shutdown-hook", 30L); + } + + private boolean awaitServerShutdownBoundary() { + if (!INMS.isBound()) { + return true; + } + try { + return INMS.get().awaitServerShutdownBoundary( + SERVER_SHUTDOWN_BOUNDARY_TIMEOUT_SECONDS, + TimeUnit.SECONDS + ); + } catch (Throwable e) { + Iris.reportError("Failed to await Paper's post-world-close shutdown boundary.", e); + return false; + } + } + + private void finishDeferredRuntimeTeardown(String reason, long timeoutSeconds) { + teardownRuntime(reason, timeoutSeconds); + finishTerminalCleanup(); + } + + private void finishTerminalCleanup() { + if (!terminalCleanupCompleted.compareAndSet(false, true)) { + return; + } + J.attempt(() -> INMS.get().uninjectBukkit()); + try { + runPostShutdown(); + } catch (Throwable e) { + Iris.reportError("Failed to run Iris post-shutdown cleanup.", e); + } finally { + IrisPlatforms.unbind(); + } + } + + private void drainWorldGenerators(String reason, long timeoutSeconds) { + List irisWorlds = new ArrayList<>(); + List generators = new ArrayList<>(); + if (serverStopTeardownDeferred.get()) { + generators.addAll(deferredShutdownGenerators); + } else { + for (World world : Bukkit.getWorlds()) { + PlatformChunkGenerator generator = IrisToolbelt.access(world); + if (generator != null) { + irisWorlds.add(world); + generators.add(generator); + } + } + } + if (generators.isEmpty()) { Iris.info("No Iris worlds to freeze."); return; } @@ -848,17 +988,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { J.attempt(PregeneratorJob::shutdownInstance); List> 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); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index 8e402db67..0c61ca86e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -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 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> cached = Optional.empty(); try { - contentFingerprint = PackValidationCache.contentFingerprint(packsRoot); + contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot); contextFingerprint = PackValidationCache.contextFingerprint(); cached = PackValidationCache.load( cacheFile, - contentFingerprint, + contentSnapshot.content(), contextFingerprint, packNames); } catch (RuntimeException exception) { @@ -88,33 +91,31 @@ public final class IrisWorldGeneratorResolver { Iris.info("Reused persisted validation for " + results.size() + " unchanged Iris pack(s); full pack parsing was skipped."); } else { - results = new ArrayList<>(packDirs.size()); - for (File packDir : packDirs) { + FreshValidation validation = validateStablePacks(packsRoot, packDirs, contentSnapshot); + packDirs = validation.packDirs(); + contentSnapshot = validation.contentSnapshot(); + results = validation.results(); + if (validation.stable()) { try { - results.add(PackValidator.validate(packDir)); - } catch (Throwable exception) { - Iris.reportError("Pack validation failed for '" + packDir.getName() + "'", exception); - String detail = exception.getMessage(); - if (detail == null || detail.isBlank()) { - detail = exception.getClass().getSimpleName(); - } - results.add(new PackValidationResult( - packDir.getName(), - List.of("Pack validation failed with " + exception.getClass().getSimpleName() - + ": " + detail), - List.of(), - System.currentTimeMillis())); + PackValidationCache.save( + cacheFile, + contentSnapshot.content(), + contextFingerprint, + results); + } catch (IOException exception) { + Iris.reportError("Could not persist Iris pack-validation results", exception); } } - try { - PackValidationCache.save(cacheFile, contentFingerprint, contextFingerprint, results); - } catch (IOException exception) { - Iris.reportError("Could not persist Iris pack-validation results", exception); - } } + Map 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 initialPackDirs, + ServerConfigurator.PackContentSnapshot initialSnapshot + ) { + List packDirs = initialPackDirs; + ServerConfigurator.PackContentSnapshot before = initialSnapshot; + for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) { + List packNames = packDirs.stream().map(File::getName).sorted().toList(); + List 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 afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); + List 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 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 validatePacks(List packDirs) { + List results = new ArrayList<>(packDirs.size()); + for (File packDir : packDirs) { + try { + results.add(PackValidator.validate(packDir)); + } catch (Throwable exception) { + Iris.reportError("Pack validation failed for '" + packDir.getName() + "'", exception); + String detail = exception.getMessage(); + if (detail == null || detail.isBlank()) { + detail = exception.getClass().getSimpleName(); + } + results.add(new PackValidationResult( + packDir.getName(), + List.of("Pack validation failed with " + exception.getClass().getSimpleName() + + ": " + detail), + List.of(), + System.currentTimeMillis())); + } + } + return results; + } + static PackValidationResult requireSnapshotLoadable(File packRoot) { Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize(); PackValidationResult result = PackValidationRegistry.get(normalizedRoot); @@ -141,22 +203,26 @@ public final class IrisWorldGeneratorResolver { synchronized (SNAPSHOT_VALIDATION_LOCK) { result = PackValidationRegistry.get(normalizedRoot); if (result == null) { - try { - result = PackValidator.validate(normalizedRoot.toFile()); - } catch (Throwable exception) { - Iris.reportError("Snapshot pack validation failed for '" + normalizedRoot + "'", exception); - String detail = exception.getMessage(); - if (detail == null || detail.isBlank()) { - detail = exception.getClass().getSimpleName(); + PackValidationRegistry.ValidationTicket ticket = + PackValidationRegistry.tryBeginValidation(normalizedRoot); + if (ticket != null) { + try { + result = PackValidator.validate(normalizedRoot.toFile()); + } catch (Throwable exception) { + Iris.reportError("Snapshot pack validation failed for '" + normalizedRoot + "'", exception); + String detail = exception.getMessage(); + if (detail == null || detail.isBlank()) { + detail = exception.getClass().getSimpleName(); + } + result = new PackValidationResult( + normalizedRoot.getFileName().toString(), + List.of("Pack validation failed with " + exception.getClass().getSimpleName() + + ": " + detail), + List.of(), + System.currentTimeMillis()); } - result = new PackValidationResult( - normalizedRoot.getFileName().toString(), - List.of("Pack validation failed with " + exception.getClass().getSimpleName() - + ": " + detail), - List.of(), - System.currentTimeMillis()); + PackValidationRegistry.publishIfCurrent(ticket, result); } - PackValidationRegistry.publish(normalizedRoot, result); } } } @@ -265,4 +331,12 @@ public final class IrisWorldGeneratorResolver { return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey()); } + + private record FreshValidation( + List packDirs, + ServerConfigurator.PackContentSnapshot contentSnapshot, + List results, + boolean stable + ) { + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java new file mode 100644 index 000000000..dadb1b80a --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java @@ -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 closeAsync()"); + String generatorCloseAsync = section(generatorSource, + "public CompletableFuture 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; + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java index 2d62f92d8..e1f1d956f 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java @@ -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( diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java index bb119fd67..f4ee99165 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java @@ -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()")); diff --git a/adapters/fabric/logs/latest.log b/adapters/fabric/logs/latest.log index 63addec2e..3174ceafc 100644 --- a/adapters/fabric/logs/latest.log +++ b/adapters/fabric/logs/latest.log @@ -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.(JSONObject.java:145) - at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:345) - at art.arcane.volmlib.util.json.JSONArray.(JSONArray.java:111) - at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:348) - at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:159) - at art.arcane.volmlib.util.json.JSONObject.(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 diff --git a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java index 1faf71e0e..30c5e2363 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java @@ -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 INPUT_DIRECTORIES = List.of("dimensions", "biomes", "snippet"); private static final String FLAT_VOID_LEVEL_STEM = """ @@ -138,18 +139,26 @@ public final class IrisDatapackCompiler { List 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 namespaces = visibleDirectories(dimensionsRoot); List 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 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 visibleDirectories(Path root) throws IOException { + try (Stream 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( diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index 02191f2af..ba88ca5cd 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -72,7 +72,9 @@ import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Comparator; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -86,6 +88,7 @@ public class ServerConfigurator { private static final Object DATAPACK_INSTALL_LOCK = new Object(); private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace"; private static final String COMPILER_INPUT_FINGERPRINT_CACHE = "datapack-compiler-input-fingerprint"; + private static final int FINGERPRINT_BUFFER_BYTES = 64 * 1024; private static volatile boolean loadedDatapackRuntimeReady; private static volatile String loadedDatapackCompilerInputFingerprint = ""; private static volatile long loadedDatapackRuntimeGeneration; @@ -401,7 +404,10 @@ public class ServerConfigurator { String current; long fingerprintStart = System.nanoTime(); try { - current = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer()); + current = restoredCompilerInputFingerprint(); + if (current.isBlank()) { + current = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer()); + } } catch (IOException | RuntimeException exception) { reportTiming(timingConsumer, "datapack_compiler_input_fingerprint", fingerprintStart); reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart); @@ -473,6 +479,13 @@ public class ServerConfigurator { IrisSettings.get().getGeneral().adjustVanillaHeight); } + static String restoredCompilerInputFingerprint() { + if (!loadedDatapackRuntimeReady || loadedDatapackRestartRequired) { + return ""; + } + return Objects.requireNonNullElse(loadedDatapackCompilerInputFingerprint, ""); + } + private static boolean pinLoadedDatapackCompilerInputs() { return pinLoadedDatapackCompilerInputs(null); } @@ -570,13 +583,11 @@ public class ServerConfigurator { List 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 packDigests = new LinkedHashMap<>(); List 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 packContents = new LinkedHashMap<>(); + for (Map.Entry 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 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 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 packContents) { + public PackContentSnapshot { + content = Objects.requireNonNullElse(content, ""); + packContents = Map.copyOf(Objects.requireNonNullElse(packContents, Map.of())); + } + } + private record FingerprintCache(String content, String metadata) { } diff --git a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java index 5dce1d5f8..21c7c3d66 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java @@ -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 RESERVED_IDS = Set.of("iris"); private static final ReentrantLock TRANSACTION_LOCK = new ReentrantLock(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); @@ -152,20 +155,23 @@ public final class DatapackIngestService { Path cacheFile = new File(root, STARTUP_VALIDATION_CACHE).toPath(); try { - String localFingerprint = startupValidationFingerprint(root, worldFolders); StartupValidationCache cached = readStartupValidationCache(cacheFile); - if (startupValidationCacheMatches( - cached, - mcVersion, - irisVersion, - autoIngest, - stripOverrides, - urls, - localFingerprint)) { - activeStartupValidation = cached; - IrisLogging.info("External datapacks match the persisted startup validation; remote resolution and full revalidation were skipped."); - IrisStartupValidation.markDatapacksReady(); - return StartupValidationOutcome.READY; + if (startupValidationContextMatches( + cached, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) { + String localFingerprint = startupValidationFingerprint(root, worldFolders); + if (startupValidationCacheMatches( + cached, + mcVersion, + irisVersion, + autoIngest, + stripOverrides, + urls, + localFingerprint)) { + activeStartupValidation = cached; + IrisLogging.info("External datapacks match the persisted startup validation; remote resolution and full revalidation were skipped."); + IrisStartupValidation.markDatapacksReady(); + return StartupValidationOutcome.READY; + } } } catch (IOException | RuntimeException exception) { IrisLogging.warn("Persisted external datapack validation could not be reused: " @@ -216,8 +222,8 @@ public final class DatapackIngestService { public static void runPostStartupTasks() { refreshWorkspaces(); - autoImportDatapackStructures(); - refreshStartupValidationAfterMaintenance(); + boolean maintenanceChanged = autoImportDatapackStructures(); + refreshStartupValidationAfterMaintenance(maintenanceChanged); } private static StartupValidationCache cacheStartupValidation( @@ -247,7 +253,10 @@ public final class DatapackIngestService { } } - private static void refreshStartupValidationAfterMaintenance() { + private static void refreshStartupValidationAfterMaintenance(boolean maintenanceChanged) { + if (!maintenanceChanged) { + return; + } StartupValidationCache validated = activeStartupValidation; if (validated == null || !IrisStartupValidation.isReady()) { return; @@ -623,6 +632,9 @@ public final class DatapackIngestService { ManifestWrite manifestWrite = null; boolean manifestDurabilityConfirmed = false; try { + for (InstallExecution install : installs) { + verifyInstallExecution(install); + } manifestWrite = prepareManifestWrite(root, manifest); manifestWrite.publish(); manifestDurabilityConfirmed = true; @@ -631,8 +643,9 @@ public final class DatapackIngestService { rollbackInstallExecutions(installs, manifestFailure); report.failed.add("manifest - " + manifestFailure.getMessage()); report.updated.clear(); + report.upToDate.clear(); report.requiresRestart = false; - message(sender, C.RED + "Datapack ingest rolled back because the manifest could not be committed: " + message(sender, C.RED + "Datapack ingest rolled back before the manifest commit: " + manifestFailure.getMessage()); IrisLogging.reportError(manifestFailure); return report; @@ -912,7 +925,7 @@ public final class DatapackIngestService { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); Path rootPath = root.toPath().toAbsolutePath().normalize(); - List entries = new ArrayList<>(); + List entries = new ArrayList<>(); try (Stream paths = Files.walk(rootPath)) { Iterator 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 publishPlans = new ArrayList<>(); + List unchangedPlans = new ArrayList<>(); try { for (InstallPlan plan : plans) { changed |= plan.contentChanged(); if (plan.publishRequired()) { publishPlans.add(plan); } else { + unchangedPlans.add(plan); cleanupInstallPlan(plan, true); } } @@ -1993,7 +2015,15 @@ public final class DatapackIngestService { throw cleanupFailure; } if (publishPlans.isEmpty()) { - return new InstallExecution(new InstallResult(changed), null); + return new InstallExecution( + new InstallResult(changed), + null, + stagedDir, + entry, + stagedHash, + verifiedStagingInstall == null, + publishPlans, + unchangedPlans); } Manifest committedManifest = readCommittedManifest(root); @@ -2033,10 +2063,44 @@ public final class DatapackIngestService { } throw publishFailure; } - return new InstallExecution(new InstallResult(changed), coordinator); + return new InstallExecution( + new InstallResult(changed), + coordinator, + stagedDir, + entry, + stagedHash, + verifiedStagingInstall == null, + publishPlans, + unchangedPlans); + } + + static void verifyInstallExecution(InstallExecution execution) throws IOException { + if (execution.verified()) { + return; + } + if (execution.verifyStagedSource()) { + validateManagedDirectory(execution.stagedDir(), execution.entry().id); + Ownership stagedOwnership = readOwnership(execution.stagedDir()); + String stagedHash = directoryHash(execution.stagedDir()); + if (!Objects.equals(stagedHash, execution.stagedHash()) + || !ownershipMetadataMatches(stagedOwnership, execution.entry(), stagedHash)) { + throw new IOException("Iris datapack staging changed before installation commit for " + + execution.entry().id); + } + } + for (InstallPlan plan : execution.publishedPlans()) { + verifyDesiredInstallSnapshot(plan.target(), plan, "published datapack target"); + } + for (InstallPlan plan : execution.unchangedPlans()) { + verifyOriginalInstallSnapshot(plan.target(), plan, "unchanged datapack target"); + } + execution.markVerified(); } static void finishInstallExecution(InstallExecution execution) throws IOException { + if (!execution.verified()) { + throw new IOException("Datapack install cannot commit before final verification"); + } if (execution.coordinator() == null) { return; } @@ -2118,6 +2182,17 @@ public final class DatapackIngestService { ) throws IOException { ensureInstallTargetRoot(worldFolder); File target = new File(worldFolder, entry.id); + InstallPlan unchanged = tryPrepareUnchangedManagedInstall( + stagedDir, + worldFolder, + target, + entry, + stagedHash, + stripOverrides, + verifiedStagingInstall); + if (unchanged != null) { + return unchanged; + } boolean canonicalStagingInstall = verifiedStagingInstall != null && verifiedStagingInstall.isCanonicalInstall(worldFolder, target); boolean legacyReplacementAuthorized = canonicalStagingInstall @@ -2134,16 +2209,22 @@ public final class DatapackIngestService { String scratchRootFileIdentity = directoryIdentity(pendingRoot); IO.copyDirectory(stagedDir.toPath(), pending.toPath()); Files.deleteIfExists(new File(pending, OWNERSHIP_MARKER).toPath()); - if (!Objects.equals(stagedHash, directoryHash(pending))) { + String copiedHash = directoryHash(pending); + if (!Objects.equals(stagedHash, copiedHash)) { throw new IOException("Datapack staging changed or copied incompletely while preparing " + entry.id); } - Files.deleteIfExists(new File(pending, OVERRIDES_STRIPPED_MARKER).toPath()); + boolean removedOverrideMarker = Files.deleteIfExists( + new File(pending, OVERRIDES_STRIPPED_MARKER).toPath()); if (stripOverrides) { stripVanillaStructureOverrides(pending); writeMarker(new File(pending, OVERRIDES_STRIPPED_MARKER)); } validatePackMetadata(pending); - writeOwnership(pending, entry); + if (!stripOverrides && !removedOverrideMarker) { + writeOwnership(pending, entry, copiedHash); + } else { + writeOwnership(pending, entry); + } validateInstallTree(pending, worldFolder, "Prepared datapack install"); Ownership desiredOwnership = readOwnership(pending); String desiredHash = desiredOwnership.contentHash; @@ -2243,6 +2324,69 @@ public final class DatapackIngestService { } } + private static InstallPlan tryPrepareUnchangedManagedInstall( + File stagedDir, + File worldFolder, + File target, + Entry entry, + String stagedHash, + boolean stripOverrides, + VerifiedStagingInstall verifiedStagingInstall + ) throws IOException { + if (verifiedStagingInstall != null + || stripOverrides + || pathExists(new File(stagedDir, OVERRIDES_STRIPPED_MARKER).toPath(), + "staged datapack override marker") + || !pathExists(target.toPath(), "datapack install target")) { + return null; + } + if (Files.isSymbolicLink(target.toPath()) + || !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath()); + } + Ownership ownership = readOwnershipOrNull(target); + if (ownership == null) { + return null; + } + if (!entry.id.equals(ownership.id)) { + throw new IOException("Datapack ownership mismatch at " + target.getPath()); + } + if (!ownershipMetadataMatches(ownership, entry, stagedHash)) { + return null; + } + validateInstallTree(target, worldFolder, "Existing datapack install"); + removeFinderMetadata(target); + String currentHash = directoryHash(target); + if (!Objects.equals(currentHash, stagedHash)) { + return null; + } + String markerHash = ownershipMarkerFingerprint(target); + String identity = directoryIdentity(target); + File pendingRoot = installScratchRoot(worldFolder); + return new InstallPlan( + target, + new File(pendingRoot, entry.id + "-" + UUID.randomUUID()), + new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID()), + pendingRoot, + true, + false, + false, + currentHash, + currentHash, + markerHash, + markerHash, + identity, + identity, + realDirectoryPath(worldFolder, "datapack target root"), + "", + directoryIdentity(worldFolder), + "", + entry.id, + entry.url, + null + ); + } + private static File installScratchRoot(File targetFolder) { File parent = targetFolder.getParentFile(); return new File(parent == null ? targetFolder : parent, ".iris-datapack-install"); @@ -2550,10 +2694,16 @@ public final class DatapackIngestService { } static void writeOwnership(File directory, Entry entry) throws IOException { + writeOwnership(directory, entry, directoryHash(directory)); + } + + private static void writeOwnership(File directory, Entry entry, String contentHash) throws IOException { if (!isValidManagedId(entry.id) || entry.url == null || entry.url.isBlank()) { throw new IOException("Invalid Iris datapack ownership identity for " + directory.getPath()); } - String contentHash = directoryHash(directory); + if (contentHash == null || contentHash.isBlank()) { + throw new IOException("Missing Iris datapack ownership content hash for " + directory.getPath()); + } Ownership ownership = new Ownership( OWNERSHIP_SCHEMA, entry.id, @@ -2640,6 +2790,10 @@ public final class DatapackIngestService { } static boolean isUsableStaging(File stagedDir, Entry entry) { + return inspectUsableStaging(stagedDir, entry).usable(); + } + + private static StagingInspection inspectUsableStaging(File stagedDir, Entry entry) { try { validateManagedDirectory(stagedDir, entry.id); Ownership ownership = readOwnership(stagedDir); @@ -2649,22 +2803,28 @@ public final class DatapackIngestService { || !Objects.equals(ownership.versionNumber, entry.versionNumber) || !Objects.equals(ownership.sha1, entry.sha1) || !Objects.equals(ownership.contentHash, contentHash)) { - return false; + return new StagingInspection(false, false, false); } PackResources resources = scanPackResources(stagedDir); + List previousStructureKeys = copyList(entry.structureKeys); + List 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 manifestEntriesByUrl = new HashMap<>(); - Map 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 packs; + try (Stream stream = ServerConfigurator.allPacks()) { + packs = stream.filter(Objects::nonNull).toList(); + } + if (!autoImportEnabled && !hasRemovedImportState(packs, manifest.entries)) { + return recovered; + } + + Map 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 completedUrls = new HashSet<>(); Set failedUrls = new HashSet<>(); - List packs; - try (Stream stream = ServerConfigurator.allPacks()) { - packs = stream.filter(Objects::nonNull).toList(); - } for (IrisData data : packs) { Set 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 packs, List entries) { + for (IrisData data : packs) { + Set 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 paths = Files.walk(root)) { - List entries = paths.limit(MAX_MANAGED_PATHS + 1L).toList(); - if (entries.size() > MAX_MANAGED_PATHS) { - throw new IOException("Datapack scratch contains too many paths: " + root); - } - for (Path entry : entries) { - BasicFileAttributes attributes = Files.readAttributes( - entry, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + int[] pathCount = new int[]{0}; + Files.walkFileTree(root, new SimpleFileVisitor<>() { + private FileVisitResult inspect(Path entry, BasicFileAttributes attributes) throws IOException { + pathCount[0]++; + if (pathCount[0] > MAX_MANAGED_PATHS) { + throw new IOException("Datapack scratch contains too many paths: " + root); + } if (attributes.isSymbolicLink() || attributes.isOther() || (!attributes.isDirectory() && !attributes.isRegularFile())) { throw new IOException("Datapack scratch contains an unsupported file: " + entry); } - if (!sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry))) { + if (!sameDatapackVolume(root, rootStore, entry)) { throw new IOException("Datapack scratch crosses a filesystem boundary: " + entry); } + return FileVisitResult.CONTINUE; } - } + + @Override + public FileVisitResult preVisitDirectory( + Path directory, + BasicFileAttributes attributes + ) throws IOException { + return inspect(directory, attributes); + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + return inspect(file, attributes); + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { + throw new IOException("Unable to inspect datapack scratch entry: " + file, failure); + } + }); } private static boolean sameScratchVolume(Path first, Path second) throws IOException { @@ -5450,7 +5666,76 @@ public final class DatapackIngestService { FAILED } - record InstallExecution(InstallResult result, DatapackCoordinator coordinator) { + static final class InstallExecution { + private final InstallResult result; + private final DatapackCoordinator coordinator; + private final File stagedDir; + private final Entry entry; + private final String stagedHash; + private final boolean verifyStagedSource; + private final List publishedPlans; + private final List unchangedPlans; + private boolean verified; + + private InstallExecution( + InstallResult result, + DatapackCoordinator coordinator, + File stagedDir, + Entry entry, + String stagedHash, + boolean verifyStagedSource, + List publishedPlans, + List 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 publishedPlans() { + return publishedPlans; + } + + private List 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 structureKeys, @@ -5840,6 +6132,9 @@ public final class DatapackIngestService { ) { } + private record MetadataEntry(Path path, String relativePath) { + } + private record DirectoryMove( File target, File backup, diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index 3a167889c..f88f93ff3 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -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> getBlockProperties(); private void validateDimensionTypes(WorldCreator c) { diff --git a/core/src/main/java/art/arcane/iris/core/nms/ServerShutdownBoundary.java b/core/src/main/java/art/arcane/iris/core/nms/ServerShutdownBoundary.java new file mode 100644 index 000000000..0d3acadb5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/nms/ServerShutdownBoundary.java @@ -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(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidationRegistry.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidationRegistry.java index 321817627..03369ea53 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidationRegistry.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidationRegistry.java @@ -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 RESULTS = new ConcurrentHashMap<>(); - private static final Map ROOT_RESULTS = new ConcurrentHashMap<>(); + private static final Map 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 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 ticket = new AtomicReference<>(); + ROOT_STATES.compute(normalizedRoot, (path, current) -> { + RootState state = current == null ? new RootState(0L, false, null) : current; + if (!state.mutating()) { + ticket.set(new ValidationTicket(normalizedRoot, state.generation())); + } + return state; + }); + return ticket.get(); + } + + public static boolean publishIfCurrent(ValidationTicket ticket, PackValidationResult result) { + if (ticket == null || result == null) { + return false; + } + AtomicBoolean published = new AtomicBoolean(); + ROOT_STATES.compute(ticket.packRoot, (path, current) -> { + if (current == null + || current.mutating() + || current.generation() != ticket.generation) { + return current; + } + published.set(true); + return new RootState( + current.generation(), + false, + new RootValidation(result, "")); + }); + return published.get(); } public static PackValidationResult get(String packName) { @@ -58,7 +134,10 @@ public final class PackValidationRegistry { if (packRoot == null) { return null; } - return ROOT_RESULTS.get(normalize(packRoot)); + RootState state = ROOT_STATES.get(normalize(packRoot)); + return state == null || state.mutating() || state.validation() == null + ? null + : state.validation().result(); } public static PackValidationResult requireLoadable(String packName) { @@ -117,22 +196,158 @@ public final class PackValidationRegistry { if (packRoot == null) { return; } - ROOT_RESULTS.remove(normalize(packRoot)); + Path normalizedRoot = normalize(packRoot); + ROOT_STATES.compute(normalizedRoot, (path, current) -> { + if (current != null && current.mutating()) { + return current; + } + return new RootState(nextGeneration(current), false, null); + }); } public static void clear() { RESULTS.clear(); - ROOT_RESULTS.clear(); + ROOT_STATES.clear(); } private static Path normalize(Path packRoot) { Path normalizedRoot = packRoot.toAbsolutePath().normalize(); try { - return normalizedRoot.toRealPath(); - } catch (NoSuchFileException exception) { - return normalizedRoot; + Path existing = normalizedRoot; + List 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) { + } } diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java index 21a737623..6dd2631a2 100644 --- a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java @@ -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 savesInProgress = new HashSet<>(); private final Set graphMutationsInProgress = new HashSet<>(); @@ -234,6 +235,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC @Override public void onEnable() { + disableStarted.set(false); enabled = true; menuController = new JigsawStudioMenuController(BukkitPlatform.volmitPlugin(), this); INSTANCE = this; @@ -241,13 +243,32 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC @Override public void onDisable() { - finalizeAllJigsawTileWatches(); - drainAutosavesBeforeDisable(); + quiesceForServerShutdown(); + } + + public void quiesceForServerShutdown() { + if (!disableStarted.compareAndSet(false, true)) { + return; + } + try { + finalizeAllJigsawTileWatches(); + } catch (Throwable exception) { + IrisLogging.reportError("Failed to finalize Jigsaw Studio tile watches during shutdown.", exception); + } + try { + drainAutosavesBeforeDisable(); + } catch (Throwable exception) { + IrisLogging.reportError("Failed to drain Jigsaw Studio autosaves during shutdown.", exception); + } enabled = false; JigsawStudioMenuController activeMenuController = menuController; menuController = null; if (activeMenuController != null) { - activeMenuController.closeAll(); + try { + activeMenuController.closeAll(); + } catch (Throwable exception) { + IrisLogging.reportError("Failed to close Jigsaw Studio menus during shutdown.", exception); + } } particlesDisabled.clear(); visualizationLoops.clear(); @@ -259,7 +280,11 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC toolConfirmations.clear(); tripleSneakTracker.clearAll(); evaluations.clear(); - previewRenderer.removeAll(); + try { + previewRenderer.removeAll(); + } catch (Throwable exception) { + IrisLogging.reportError("Failed to remove Jigsaw Studio previews during shutdown.", exception); + } reopenRequiredRequests.clear(); unregisterRetries.clear(); unregisterDrainWarnings.clear(); @@ -276,6 +301,9 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC } public void register(Engine engine, JigsawStudioGenerator generator) { + if (!enabled || disableStarted.get()) { + return; + } Engine activeEngine = Objects.requireNonNull(engine, "Jigsaw Studio engine"); JigsawStudioGenerator activeGenerator = Objects.requireNonNull(generator, "Jigsaw Studio generator"); World world = BukkitWorldBinding.world(activeEngine.getTarget().getWorld()); @@ -306,6 +334,9 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC new AtomicLong()); UUID displacedRequestId = null; synchronized (saveLifecycleLock) { + if (!enabled || disableStarted.get()) { + return; + } ActiveStudio previous = studios.get(world.getUID()); if (previous != null && previous.generator() == activeGenerator) { return; @@ -338,7 +369,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC } public void activationCommitted(World world, UUID requestId) { - if (world == null || requestId == null) { + if (!enabled || disableStarted.get() || world == null || requestId == null) { return; } ActiveStudio studio = studios.get(world.getUID()); @@ -355,7 +386,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC int chunkX, int chunkZ ) { - if (engine == null || generator == null) { + if (!enabled || disableStarted.get() || engine == null || generator == null) { return; } World world = BukkitWorldBinding.world(engine.getTarget().getWorld()); @@ -363,7 +394,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC return; } ActiveStudio studio = studios.get(world.getUID()); - if (studio == null || studio.engine() != engine || studio.generator() != generator) { + if (!enabled || disableStarted.get() + || studio == null || studio.engine() != engine || studio.generator() != generator) { return; } markChunkAvailable(studio, chunkX, chunkZ); @@ -5259,7 +5291,11 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC private void finalizeAllJigsawTileWatches() { for (JigsawTileWatch watch : List.copyOf(jigsawTileWatches.values())) { - finalizeJigsawTileWatch(watch); + try { + finalizeJigsawTileWatch(watch); + } catch (Throwable exception) { + IrisLogging.reportError("Failed to finalize a Jigsaw Studio tile watch during shutdown.", exception); + } } } @@ -5655,7 +5691,14 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC private void drainAutosavesBeforeDisable() { for (ActiveStudio studio : List.copyOf(studios.values())) { - drainAutosavesBeforeRemoval(studio); + try { + drainAutosavesBeforeRemoval(studio); + } catch (Throwable exception) { + IrisLogging.reportError( + "Failed to drain Jigsaw Studio autosaves in world " + + studio.worldId() + " during shutdown.", + exception); + } } if (!autosaves.isEmpty()) { IrisLogging.warn("Jigsaw Studio disabled with %d autosave operation(s) still pending after the final drain attempt", diff --git a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java index e52cfb9b7..816670436 100644 --- a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java @@ -29,6 +29,7 @@ import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.AtomicDirectoryPublisher; +import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackDownloadExecution; import art.arcane.iris.core.pack.PackDownloader; @@ -64,12 +65,14 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.FileAlreadyExistsException; +import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; -import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -82,7 +85,6 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.regex.Pattern; -import java.util.stream.Stream; import art.arcane.iris.core.localization.BukkitRuntimeMessages; import art.arcane.iris.core.localization.IrisLanguage; @@ -198,6 +200,7 @@ public class StudioSVC implements IrisService { Path parent = target.getParent(); Path stage = null; AtomicDirectoryPublisher.Publication publication = null; + PackValidationRegistry.RootMutation validationMutation = null; IrisData previousData = IrisData.getLoaded(target.toFile()).orElse(null); IrisData createdData = null; boolean refreshedPreviousData = false; @@ -207,10 +210,7 @@ public class StudioSVC implements IrisService { throw new IOException("World pack target has no parent: " + target); } Files.createDirectories(parent); - if (!replaceExisting - && (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target))) { - throw new FileAlreadyExistsException(target.toString()); - } + requireSafePublicationTarget(target, replaceExisting); stage = Files.createTempDirectory(parent, ".pack.installing-"); copyPackTree(source, stage); @@ -223,9 +223,18 @@ public class StudioSVC implements IrisService { } finally { stagedData.close(); } + validationMutation = PackValidationRegistry.beginRootMutation(target); + requireSafePublicationTarget(target, replaceExisting); publication = AtomicDirectoryPublisher.publish(stage, target); stage = null; - validatePublishedPack(target); + String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile()); + PackValidationResult publishedValidation = + validatePublishedPack(target, source, copiedFingerprint, validationMutation); + if (!publishedValidation.isLoadable()) { + throw new BrokenPackException( + target.toString(), + publishedValidation.getBlockingErrors()); + } IrisData installedData; // Live engines only ever attach to detached openRuntime loaders, never to the @@ -249,6 +258,7 @@ public class StudioSVC implements IrisService { throw new IOException("Published pack does not contain a loadable dimension '" + dimensionKey + "'."); } publication.commit(); + validationMutation.commit(); try { publication.cleanupBackup(); } catch (IOException cleanupFailure) { @@ -275,6 +285,9 @@ public class StudioSVC implements IrisService { sender.sendMessage("Failed to install studio pack '" + dimensionKey + "': " + errorDetail(e)); return null; } finally { + if (validationMutation != null) { + validationMutation.close(); + } if (stage != null) { try { AtomicDirectoryPublisher.deleteTree(stage); @@ -289,11 +302,56 @@ public class StudioSVC implements IrisService { PackValidationRegistry.remove(packRoot); } + static void requireSafePublicationTarget(Path target, boolean replaceExisting) throws IOException { + if (Files.isSymbolicLink(target)) { + throw new IOException("World pack target is a symbolic link: " + target); + } + if (!replaceExisting && Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw new FileAlreadyExistsException(target.toString()); + } + } + static PackValidationResult validatePublishedPack(Path packRoot) { - invalidatePackValidation(packRoot); - PackValidationResult result = PackValidator.validate(packRoot.toFile()); - PackValidationRegistry.publish(packRoot, result); - return PackValidationRegistry.requireLoadable(packRoot); + try (PackValidationRegistry.RootMutation mutation = + PackValidationRegistry.beginRootMutation(packRoot)) { + PackValidationResult result = PackValidator.validate(packRoot.toFile()); + mutation.stage(result); + mutation.commit(); + return PackValidationRegistry.requireLoadable(packRoot); + } + } + + static PackValidationResult validatePublishedPack( + Path packRoot, + Path validatedSource, + String copiedContentFingerprint + ) { + try (PackValidationRegistry.RootMutation mutation = + PackValidationRegistry.beginRootMutation(packRoot)) { + PackValidationResult result = validatePublishedPack( + packRoot, + validatedSource, + copiedContentFingerprint, + mutation); + mutation.commit(); + return PackValidationRegistry.requireLoadable(packRoot); + } + } + + private static PackValidationResult validatePublishedPack( + Path packRoot, + Path validatedSource, + String copiedContentFingerprint, + PackValidationRegistry.RootMutation mutation + ) { + PackValidationResult result = mutation.stageMatchingCopy( + validatedSource, + copiedContentFingerprint); + if (result == null) { + result = PackValidator.validate(packRoot.toFile()); + mutation.stage(result); + } + return result; } static void rollbackFailedPublication( @@ -318,7 +376,6 @@ public class StudioSVC implements IrisService { } static Path resolveSafePackSource(File sourceFolder) throws IOException { - PackDirectoryResolver.requireSafePackTree(sourceFolder); Path source = sourceFolder.toPath().toAbsolutePath().normalize().toRealPath(); if (!Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(source)) { throw new IOException("Source pack is missing or unsafe: " + sourceFolder); @@ -1202,25 +1259,85 @@ public class StudioSVC implements IrisService { } static void copyPackTree(Path source, Path target) throws IOException { - try (Stream entries = Files.walk(source)) { - for (Path entry : entries.sorted(Comparator.naturalOrder()).toList()) { - if (Files.isSymbolicLink(entry)) { - throw new IOException("Pack contains a symbolic link: " + entry); - } - Path destination = target.resolve(source.relativize(entry)).normalize(); - if (!destination.startsWith(target)) { - throw new IOException("Pack entry escapes its installation stage: " + entry); - } - if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) { - Files.createDirectories(destination); - } else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { - Files.createDirectories(destination.getParent()); - Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES); - } else { - throw new IOException("Pack contains an unsupported entry: " + entry); - } - } + Path normalizedSource = source.toRealPath(); + if (!Files.isDirectory(normalizedSource, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Pack source is not a directory: " + normalizedSource); } + Path requestedTarget = target.toAbsolutePath().normalize(); + if (Files.isSymbolicLink(requestedTarget)) { + throw new IOException("Pack installation stage is a symbolic link: " + requestedTarget); + } + Path normalizedTarget; + if (Files.exists(requestedTarget, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(requestedTarget, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Pack installation stage is not a directory: " + requestedTarget); + } + normalizedTarget = requestedTarget.toRealPath(); + } else { + Path parent = Objects.requireNonNull( + requestedTarget.getParent(), + "Pack installation stage parent"); + normalizedTarget = parent.toRealPath().resolve(requestedTarget.getFileName()).normalize(); + } + if (normalizedTarget.startsWith(normalizedSource) + || normalizedSource.startsWith(normalizedTarget)) { + throw new IOException("Pack source and installation stage overlap: " + + normalizedSource + " and " + normalizedTarget); + } + Files.walkFileTree(normalizedSource, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory( + Path directory, + BasicFileAttributes attributes + ) throws IOException { + if (attributes.isSymbolicLink()) { + throw new IOException("Pack contains a symbolic link: " + directory); + } + if (!directory.equals(normalizedSource) + && normalizedSource.relativize(directory).getNameCount() == 1 + && PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + Files.createDirectories(copyDestination( + normalizedSource, + normalizedTarget, + directory)); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + if (attributes.isSymbolicLink()) { + throw new IOException("Pack contains a symbolic link: " + file); + } + if (!attributes.isRegularFile()) { + throw new IOException("Pack contains an unsupported entry: " + file); + } + String fileName = file.getFileName().toString(); + if ((normalizedSource.relativize(file).getNameCount() == 1 + && PackDirectoryResolver.isHiddenName(fileName)) + || fileName.endsWith(".code-workspace")) { + return FileVisitResult.CONTINUE; + } + Path destination = copyDestination(normalizedSource, normalizedTarget, file); + Files.createDirectories(Objects.requireNonNull(destination.getParent(), "Pack entry parent")); + Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { + throw new IOException("Unable to copy pack entry: " + file, failure); + } + }); + } + + private static Path copyDestination(Path source, Path target, Path entry) throws IOException { + Path destination = target.resolve(source.relativize(entry)).normalize(); + if (!destination.startsWith(target)) { + throw new IOException("Pack entry escapes its installation stage: " + entry); + } + return destination; } static void publishNewDirectory(Path stage, Path target) throws IOException { diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index 7fb78cc47..e925309f9 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -97,6 +97,7 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML; @Accessors(fluent = true, chain = true) public class IrisCreator { private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L; + private static final long WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS = 60L; private static final long ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L; /** @@ -398,17 +399,42 @@ public class IrisCreator { return; } + Throwable failure = awaitTeleportFailure( + teleportFuture, + player.getName(), + WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS, + TimeUnit.SECONDS + ); + if (failure != null) { + reportSenderTeleportFailure(player, world, failure); + } + } + + static Throwable awaitTeleportFailure( + CompletableFuture teleportFuture, + String playerName, + long timeout, + TimeUnit unit + ) { + CompletableFuture requiredFuture = Objects.requireNonNull(teleportFuture, "teleportFuture"); + String requiredPlayerName = Objects.requireNonNull(playerName, "playerName"); + TimeUnit requiredUnit = Objects.requireNonNull(unit, "unit"); try { - Boolean teleported = teleportFuture.get(60L, TimeUnit.SECONDS); - if (!Boolean.TRUE.equals(teleported)) { - reportSenderTeleportFailure(player, world, new IllegalStateException( - "The runtime teleport operation returned false for player \"" + player.getName() + "\".")); - } + Boolean teleported = requiredFuture.get(timeout, requiredUnit); + return Boolean.TRUE.equals(teleported) + ? null + : new IllegalStateException( + "The runtime teleport operation returned false for player \"" + requiredPlayerName + "\"."); } catch (TimeoutException e) { - ServerConfigurator.restart("World entry teleport timed out for \"" + world.getName() + "\"."); - reportSenderTeleportFailure(player, world, e); + requiredFuture.cancel(false); + return e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return e; + } catch (ExecutionException e) { + return e.getCause() == null ? e : e.getCause(); } catch (Throwable e) { - reportSenderTeleportFailure(player, world, e); + return e; } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 42568364e..04275ff51 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -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; diff --git a/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java index 2a8c12851..6ed15e9ed 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/PlatformChunkGenerator.java @@ -66,6 +66,9 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider { } } + default void quiesceForServerShutdown() { + } + boolean isStudio(); default boolean isClosing() { diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java index 5c2a50662..e8975d700 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java @@ -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 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"); diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java index 01f229257..3c1348871 100644 --- a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java @@ -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"); diff --git a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java index 791801f7c..a76620c25 100644 --- a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java +++ b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java @@ -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 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 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 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, diff --git a/core/src/test/java/art/arcane/iris/core/nms/ServerShutdownBoundaryTest.java b/core/src/test/java/art/arcane/iris/core/nms/ServerShutdownBoundaryTest.java new file mode 100644 index 000000000..9cb76368f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/nms/ServerShutdownBoundaryTest.java @@ -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 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); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidationRegistryTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidationRegistryTest.java index 6a44ec3c2..dfaf40d7d 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackValidationRegistryTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidationRegistryTest.java @@ -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 ticket = new AtomicReference<>(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future 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; diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioServiceCaptureTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioServiceCaptureTest.java index 924cb094d..794854461 100644 --- a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioServiceCaptureTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioServiceCaptureTest.java @@ -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 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); diff --git a/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java b/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java index a49681cef..2f51f97f7 100644 --- a/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java @@ -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(); diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorTeleportTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorTeleportTest.java index fde3e2712..bb4e983d9 100644 --- a/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorTeleportTest.java +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorTeleportTest.java @@ -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 teleport = CompletableFuture.completedFuture(true); + + Throwable failure = IrisCreator.awaitTeleportFailure( + teleport, + "ParthOP69", + 1L, + TimeUnit.SECONDS + ); + + assertNull(failure); + assertFalse(teleport.isCancelled()); + } + + @Test + public void awaitTeleportFailure_reportsFalseTeleportResult() { + CompletableFuture 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 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 teleport = new CompletableFuture<>(); + + try (MockedStatic serverConfigurator = mockStatic(ServerConfigurator.class)) { + Throwable failure = IrisCreator.awaitTeleportFailure( + teleport, + "ParthOP69", + 0L, + TimeUnit.MILLISECONDS + ); + + assertTrue(failure instanceof TimeoutException); + assertTrue(teleport.isCancelled()); + serverConfigurator.verifyNoInteractions(); + } + } } diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java index 398f6a35b..f16eb2c77 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java @@ -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 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);