From 5cacf6d43565b396967dc78d02930e4123225b04 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 20 Aug 2026 10:59:17 -0400 Subject: [PATCH] MV --- .../src/main/java/art/arcane/iris/Iris.java | 119 +++- .../java/art/arcane/iris/IrisBootstrap.java | 78 +++ .../iris/core/IrisWorldGeneratorResolver.java | 75 ++- .../iris/core/service/IrisEngineSVC.java | 47 +- ...IrisBootstrapWorldStorageContractTest.java | 194 ++++++ .../iris/IrisDiagnosticLogLevelTest.java | 89 +++ .../core/IrisWorldGeneratorResolverTest.java | 88 ++- .../service/IrisApiWiringContractTest.java | 2 +- .../IrisEngineLifecycleContractTest.java | 4 +- .../IrisEngineSVCUntrackedUnloadTest.java | 59 ++ .../art/arcane/iris/modded/ModdedIrisLog.java | 6 +- .../ModdedIrisLogLevelCoverageTest.java | 27 + core/build.gradle | 2 +- core/purity-allowlist.txt | 2 + .../arcane/iris/core/IrisWorldStorage.java | 24 + .../java/art/arcane/iris/core/IrisWorlds.java | 106 +++- .../arcane/iris/core/ServerConfigurator.java | 7 +- .../art/arcane/iris/core/gui/GuiHost.java | 2 +- .../lifecycle/BukkitWorldConfiguration.java | 335 ++++++++-- .../core/lifecycle/HuskWorldQuarantine.java | 252 ++++++++ .../core/lifecycle/ManagedWorldLoader.java | 38 ++ .../lifecycle/MissingWorldStorageLog.java | 118 ++++ .../core/lifecycle/VanishedWorldStorage.java | 81 +++ .../iris/core/link/MultiverseCoreLink.java | 590 ++++++++++++++++-- .../core/link/MultiverseGuardListener.java | 487 +++++++++++++++ .../art/arcane/iris/core/loader/IrisData.java | 4 - .../core/pregenerator/IrisPregenerator.java | 2 +- .../core/pregenerator/MantleHeapPressure.java | 4 +- .../PregenMantleBackpressure.java | 6 +- .../pregenerator/cache/PregenCacheImpl.java | 27 +- .../methods/AsyncPregenMethod.java | 4 +- .../iris/core/safeguard/IrisSafeguard.java | 10 +- .../iris/core/service/MultiverseSVC.java | 109 ++++ .../arcane/iris/engine/EngineDataStore.java | 63 +- .../art/arcane/iris/engine/IrisComplex.java | 7 +- .../art/arcane/iris/engine/IrisEngine.java | 2 +- .../iris/engine/data/cache/AtomicCache.java | 64 +- .../framework/placer/WorldObjectPlacer.java | 4 +- .../iris/engine/mantle/MantleWriter.java | 6 +- .../arcane/iris/engine/object/IrisCompat.java | 4 +- .../iris/engine/object/IrisDecorator.java | 2 +- .../arcane/iris/engine/object/IrisImage.java | 21 - .../iris/engine/object/IrisImageMap.java | 4 +- .../arcane/iris/util/common/misc/SlimJar.java | 4 +- .../arcane/iris/util/project/hunk/Hunk.java | 4 +- .../core/DiagnosticSeverityPolicyTest.java | 150 +++++ .../iris/core/IrisWorldStorageTest.java | 17 + .../art/arcane/iris/core/IrisWorldsTest.java | 51 ++ .../BukkitWorldConfigurationTest.java | 180 ++++++ .../lifecycle/HuskWorldQuarantineTest.java | 269 ++++++++ .../lifecycle/MissingWorldStorageLogTest.java | 123 ++++ .../lifecycle/WorldRemovalPathPolicyTest.java | 30 + .../core/link/MultiverseCoreLinkTest.java | 264 +++++++- .../link/MultiverseGuardListenerTest.java | 406 ++++++++++++ .../iris/core/service/MultiverseSVCTest.java | 116 ++++ .../engine/IrisEngineDataPersistenceTest.java | 70 ++- .../engine/data/cache/AtomicCacheTest.java | 106 ++++ .../art/arcane/iris/spi/IrisLoggingTest.java | 130 ++-- gradle/libs.versions.toml | 2 +- .../java/art/arcane/iris/spi/IrisLogging.java | 63 ++ .../art/arcane/iris/spi/IrisPlatforms.java | 5 + .../java/art/arcane/iris/spi/LogLevel.java | 6 + 62 files changed, 4893 insertions(+), 278 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineSVCUntrackedUnloadTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedIrisLogLevelCoverageTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/ManagedWorldLoader.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLog.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/VanishedWorldStorage.java create mode 100644 core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java create mode 100644 core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLogTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/link/MultiverseGuardListenerTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/MultiverseSVCTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/data/cache/AtomicCacheTest.java 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 8f8ae6bad..58f521cde 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 @@ -31,12 +31,15 @@ import art.arcane.iris.core.IrisStartupValidation; import art.arcane.iris.core.IrisStartupAdmissionListener; import art.arcane.iris.core.BukkitWorldReconciler; import art.arcane.iris.core.IrisWorldGeneratorResolver; +import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.PendingWorldDeleteQueue; import art.arcane.iris.core.PendingWorldReplacementManager; import art.arcane.iris.core.SettingsHotloadWatch; import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.datapack.DatapackIngestService; import art.arcane.iris.core.datapack.DatapackIngestService.StartupValidationOutcome; +import art.arcane.iris.core.lifecycle.ManagedWorldLoader; +import art.arcane.iris.core.lifecycle.MissingWorldStorageLog; import art.arcane.iris.core.lifecycle.PaperLibBootstrap; import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks; @@ -65,6 +68,7 @@ import art.arcane.iris.core.service.IrisProtocolService; import art.arcane.iris.core.service.IrisTerrainSVC; import art.arcane.iris.core.service.JigsawStudioService; import art.arcane.iris.core.service.LogFilterSVC; +import art.arcane.iris.core.service.MultiverseSVC; import art.arcane.iris.core.service.ObjectSVC; import art.arcane.iris.core.service.ObjectStudioSaveService; import art.arcane.iris.core.service.PreservationSVC; @@ -100,6 +104,7 @@ import art.arcane.volmlib.util.io.InstanceState; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.common.misc.Bindings; +import art.arcane.iris.util.common.misc.ServerProperties; import art.arcane.iris.util.common.misc.SlimJar; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.common.plugin.IrisService; @@ -142,6 +147,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -261,12 +267,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } } + /** + * A warning raised here is the same kind of thing as one raised in core, so it takes the same route: the + * plugin logger at WARNING, where a log scan of logs/latest.log finds it. + */ public static void warn(String format, Object... objs) { - msg(C.YELLOW + safeFormat(format, objs)); + diagnostic(Level.WARNING, safeFormat(format, objs)); } public static void error(String format, Object... objs) { - msg(C.RED + safeFormat(format, objs)); + diagnostic(Level.SEVERE, safeFormat(format, objs)); } public static void debug(String string) { @@ -512,12 +522,52 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private static void bridgeLog(LogLevel level, String message) { LogLevel target = level == null ? LogLevel.INFO : level; - switch (target) { - case DEBUG -> Iris.debug(message); - case INFO -> Iris.info(message); - case WARN -> Iris.warn(message); - case ERROR -> Iris.error(message); + Level diagnostic = diagnosticLevel(target); + if (diagnostic != null) { + diagnostic(diagnostic, message); + return; } + if (target == LogLevel.DEBUG) { + Iris.debug(message); + return; + } + Iris.info(message); + } + + /** + * The java.util.logging level a message keeps, or null when it belongs on the console sender path. + *

+ * Core states a severity and every other adapter honours it; on Bukkit a coloured line through the + * console sender reaches the terminal but not the instance's logs/latest.log, so a WARN-level scan of + * that file never saw a single core warning. Diagnostics go to the plugin logger at their own level + * instead, and NOTICE carries the handful of lifecycle lines that have to land there too. Player-facing + * text still goes through {@code IrisLogging.msg}. + */ + static Level diagnosticLevel(LogLevel level) { + return switch (level) { + case NOTICE -> Level.INFO; + case WARN -> Level.WARNING; + case ERROR -> Level.SEVERE; + case DEBUG, INFO -> null; + }; + } + + private static void diagnostic(Level level, String message) { + String line = IrisLogging.clean(message); + Iris plugin = instance; + if (plugin != null) { + try { + plugin.getLogger().log(level, line); + return; + } catch (Throwable unavailable) { + // Paper runs the bootstrap before a plugin logger exists; the streams below are all there is. + } + } + if (level.intValue() >= Level.WARNING.intValue()) { + System.err.println("[Iris] " + line); + return; + } + System.out.println("[Iris] " + line); } /** @@ -573,6 +623,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { new WandSVC(), new BoardSVC(), new IrisIntegrationService(), + new MultiverseSVC(), new IrisProtocolService(), new IrisApiEventSVC(), new CommandSVC() @@ -597,6 +648,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { J.attempt(this::splash); IrisSafeguard.printReports(); IrisSafeguard.printFooter(); + // Paper's bootstrap runs before any plugin logger exists, so orphan-storage warnings raised there + // never reach logs/latest.log. Replay them once now that the platform log is up. + MissingWorldStorageLog.replayToPlatformLog(); tickets = new ChunkTickets(); linkMultiverseCore = new MultiverseCoreLink(); IrisServices.register(MultiverseCoreLink.class, linkMultiverseCore); @@ -606,6 +660,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) IrisWorldManager::new); IrisServices.register(WorldDeletionQueue.class, pendingWorldDeletes); + IrisServices.register(ManagedWorldLoader.class, (ManagedWorldLoader) this::loadManagedWorld); SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json")); settingsHotloadWatch = watch; configHotloadEngine = new ConfigHotloadEngine( @@ -710,6 +765,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return worldReconciler; } + /** + * The load every integration goes through. {@code /iris load} and the Multiverse guard both land here, + * so a world loaded from outside Iris still gets the keyed creator, the pack's environment and the + * bukkit.yml reconciliation that make it an Iris world rather than a vanilla one under the same name. + */ + private CompletableFuture loadManagedWorld(String configuredWorldName) { + return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName) + .thenApply(result -> new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message())); + } + public PendingWorldReplacementManager pendingWorldReplacements() { return pendingWorldReplacements; } @@ -757,8 +822,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this); pendingWorldReplacements.registerPlatformEntryListener(); - boolean enabled = enable(); + boolean enabled; + try { + enabled = enable(); + } catch (Throwable failure) { + refuseVanillaFallback(failure); + throw failure; + } if (!enabled) { + refuseVanillaFallback(null); return; } BukkitGuiHost.install(); @@ -766,6 +838,37 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { super.onEnable(); } + /** + * Stops a server whose Iris worlds would otherwise be generated by the vanilla generator. + *

+ * A disabled Iris gets no {@code getDefaultWorldGenerator} call at all, so the server falls back to + * vanilla for every world bukkit.yml points at Iris and writes vanilla terrain into their region files. + * There is no Bukkit API that refuses a world at that point, so the server is stopped instead. This is + * damage control, not prevention: level creation runs in the same startup step that enables plugins, so + * spawn chunks of the affected worlds can still be written before the stop takes effect. The prevention + * lives in IrisBootstrap, which refuses startup before any level is created. + */ + private static void refuseVanillaFallback(Throwable failure) { + File levelRoot; + try { + levelRoot = IrisWorldStorage.levelRoot(); + } catch (Throwable unavailable) { + return; + } + if (!IrisWorldStorage.hasManagedWorldStorage(levelRoot)) { + return; + } + Iris.error("Iris did not enable and this server has Iris worlds; stopping the server before they generate vanilla terrain."); + if (failure != null) { + Iris.reportError("Iris enable failed", failure); + } + try { + Bukkit.shutdown(); + } catch (Throwable unavailable) { + Iris.error("Could not stop the server: " + unavailable.getClass().getSimpleName()); + } + } + public void onDisable() { teardownPapi(); boolean serverStopping = IrisToolbelt.isServerStopping(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java index 135675c0e..039b3a5f5 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java @@ -1,6 +1,10 @@ package art.arcane.iris; import art.arcane.iris.core.lifecycle.BukkitStartupPaths; +import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration; +import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisWorldStorageEntry; +import art.arcane.iris.core.lifecycle.HuskWorldQuarantine; +import art.arcane.iris.core.lifecycle.MissingWorldStorageLog; import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap; import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner; @@ -13,6 +17,8 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; import java.io.IOException; import java.nio.file.Path; +import java.util.List; +import java.util.function.Consumer; @SuppressWarnings("UnstableApiUsage") public final class IrisBootstrap implements PluginBootstrap { @@ -22,6 +28,8 @@ public final class IrisBootstrap implements PluginBootstrap { try { BukkitStartupPaths startupPaths = BukkitStartupPaths.resolveCurrent(); reconcilePendingWorldReplacements(context, startupPaths); + quarantineWorthlessHusks(startupPaths, message -> context.getLogger().warn(message)); + requireUsableWorldStorage(startupPaths); ProvisionResult provisioned = provision(context, startupPaths); Path datapackRoot = provisioned.datapackRoot(); context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot); @@ -58,6 +66,76 @@ public final class IrisBootstrap implements PluginBootstrap { } } + /** + * Moves a hot-deleted world's husk out of {@code /dimensions/iris} before anything enumerates + * it. + *

+ * Deleting a loaded world's folder and letting the server save the level back leaves a directory holding + * only the server's own {@code data/} skeleton. Left there it stops the boot whichever way it is + * classified: as unusable storage it trips the guard below, and as an empty folder it is excluded from + * the dimension registry, which trips Paper's interactive world-migration gate - a prompt that consumes + * no console input and hangs startup forever. The folder is moved rather than deleted, so it stays + * recoverable; {@link HuskWorldQuarantine} refuses anything that is not provably worthless. + */ + static void quarantineWorthlessHusks(BukkitStartupPaths startupPaths, Consumer warn) { + HuskWorldQuarantine.quarantineWorthlessHusks(startupPaths.levelRoot(), warn); + } + + /** + * Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from. + *

+ * The server enumerates levels from {@code /dimensions//} on disk, so a world + * whose folder is present is going to be loaded whatever Iris does; and CraftBukkit swallows whatever + * {@code getDefaultWorldGenerator} throws and falls back to the vanilla generator, which writes vanilla + * terrain straight into that world's region files. A pack snapshot that has gone missing therefore has + * to be found here, before any level is created, exactly as a corrupt {@code dimensions/.json} is. + *

+ * A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and + * its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither + * is a folder that holds no pack snapshot and no world data - {@link #quarantineWorthlessHusks} has + * already moved those out of the dimensions tree, and one that survives it (a Spigot-layout world folder, + * or a move that failed) owns nothing a vanilla generator could destroy. Both are only reported. + */ + static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException { + Path levelRoot = startupPaths.levelRoot(); + Path levelName = levelRoot.getFileName(); + if (levelName == null || levelName.toString().isBlank()) { + throw new IOException("Configured level root has no startup level id: " + levelRoot); + } + List entries = BukkitWorldConfiguration.auditIrisWorldStorage( + startupPaths.bukkitConfiguration().toFile(), + levelName.toString(), + levelRoot + ); + StringBuilder unusable = new StringBuilder(); + for (IrisWorldStorageEntry entry : entries) { + switch (entry.state()) { + case PRESENT -> { + } + case MISSING -> MissingWorldStorageLog.warnOnce( + entry.configuredWorldName(), + entry.storagePath()); + case EMPTY -> MissingWorldStorageLog.warnEmptyOnce( + entry.configuredWorldName(), + entry.storagePath()); + case UNUSABLE -> unusable.append(unusable.isEmpty() ? "" : "; ") + .append(entry.configuredWorldName()) + .append(" (") + .append(entry.storagePath()) + .append(": ") + .append(entry.detail() == null ? "storage is unusable" : entry.detail()) + .append(')'); + } + } + if (unusable.isEmpty()) { + return; + } + throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla" + + " terrain over it: " + unusable + + ". Restore each world folder from a backup, or move it aside; a world with no folder is" + + " reported and skipped, and the server starts."); + } + static void armStartupFailure(BootstrapContext context, Throwable failure) { armStartupFailure(context, failure, LifecycleEvents.DATAPACK_DISCOVERY); } 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 222ec94bc..cbb8dd2db 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 @@ -28,6 +28,7 @@ import art.arcane.iris.core.pack.PackValidationCache; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisWorld; @@ -237,7 +238,7 @@ public final class IrisWorldGeneratorResolver { @Nullable public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) { File levelRoot = IrisWorldStorage.levelRoot(); - NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName()); + NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot); String configuredWorldName = IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName()); File pack = IrisWorldStorage.frozenDimensionRoot( Bukkit.getWorldContainer(), @@ -263,8 +264,65 @@ public final class IrisWorldGeneratorResolver { return dimension; } - static NamespacedKey configuredWorldKey(String worldName, String levelName) { - return IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName); + /** + * Resolves the Iris key behind a Bukkit world name, accepting the runtime keyed name alongside the + * startup name. + *

+ * Paper names a world built from {@code WorldCreator.ofKey} {@code _}, and Multiverse + * loads Iris worlds that way because its own keyed creator refuses a non-{@code minecraft} namespace. + * That is not the startup name, so the configured-name parser reads {@code iris_moon} as + * {@code iris:iris_moon} and loses the world. + *

+ * Only a name whose decoded key already has Iris storage is remapped, and only when the literal + * reading has none, so a world genuinely created as {@code /iris create "iris moon"} keeps its own + * identity and a server without Multiverse resolves exactly the names it resolved before. + */ + static NamespacedKey configuredWorldKey(String worldName, String levelName, File levelRoot) { + NamespacedKey literal = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName); + String runtimePrefix = IRIS_DIMENSION_NAMESPACE + "_"; + if (!worldName.startsWith(runtimePrefix) + || worldName.startsWith(levelName + "_" + runtimePrefix) + || IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, literal)) { + return literal; + } + NamespacedKey runtime; + try { + runtime = IrisWorldStorage.managedKeyFromName( + IRIS_DIMENSION_NAMESPACE + ":" + worldName.substring(runtimePrefix.length()), + levelName); + } catch (RuntimeException notAManagedKey) { + return literal; + } + if (!IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, runtime)) { + return literal; + } + IrisLogging.debug("Resolved runtime keyed world name " + worldName + " as " + runtime); + return runtime; + } + + /** + * The key a refusal message should name for a world name, without the storage-existence guard. + *

+ * {@link #configuredWorldKey} only remaps the runtime keyed name when the decoded key has storage, so an + * orphan keeps the literal reading and a message built from it names {@code iris:iris_moon} and tells + * the admin to create a world that never existed under that name. Messaging does not have to be safe + * against mis-mapping a live world, only correct about what the admin typed, so it drops that guard; + * generation keeps it. + */ + static NamespacedKey messageWorldKey(String worldName, String levelName) { + NamespacedKey literal = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName); + String runtimePrefix = IRIS_DIMENSION_NAMESPACE + "_"; + if (!worldName.startsWith(runtimePrefix) + || worldName.startsWith(levelName + "_" + runtimePrefix)) { + return literal; + } + try { + return IrisWorldStorage.managedKeyFromName( + IRIS_DIMENSION_NAMESPACE + ":" + worldName.substring(runtimePrefix.length()), + levelName); + } catch (RuntimeException notAManagedKey) { + return literal; + } } /** @@ -298,7 +356,7 @@ public final class IrisWorldGeneratorResolver { Iris.debug("Generator ID: " + id + " requested by bukkit/plugin"); File levelRoot = IrisWorldStorage.levelRoot(); - NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName()); + NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot); requireWorldKeyAvailable(worldName, worldKey); requireOwnedWorld(worldName, levelRoot, worldKey); @@ -361,10 +419,11 @@ public final class IrisWorldGeneratorResolver { && (IRIS_DIMENSION_NAMESPACE.equals(worldKey.getNamespace()) || hasFrozenPack(dimensionRoot))) { return; } - throw new IllegalStateException("'" + worldName + "' (" + worldKey + NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName()); + throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") has no Iris world storage, so Iris cannot generate it." - + " Create Iris worlds with /iris create " + worldName + " type=;" - + " Iris registers them with Multiverse itself."); + + " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey) + + " type=; Iris registers them with Multiverse itself."); } private static boolean hasFrozenPack(File dimensionRoot) { @@ -374,7 +433,7 @@ public final class IrisWorldGeneratorResolver { private ChunkGenerator resolveFrozenWorldGenerator(String worldName, String id) { File levelRoot = IrisWorldStorage.levelRoot(); - NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName()); + NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot); File dimensionRoot = IrisWorldStorage.requireFrozenDimensionRoot( Bukkit.getWorldContainer(), levelRoot, diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java index 87e7515eb..3f10b882d 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java @@ -162,7 +162,37 @@ public final class IrisEngineSVC implements IrisService { World world = event.getWorld(); CompletionStage unloadBoundary = WorldUnloadBoundaryRegistry.claim( WorldIdentity.serialize(world)); - remove(world, unloadBoundary); + if (remove(world, unloadBoundary) || unloadBoundary != null) { + return; + } + closeUntrackedGenerator(world); + } + + /** + * Closes the engine of a world this service never registered. + *

+ * Registration is skipped while a previous generator for the same identity is still closing and while + * the world is not yet the server's world for its UID, and an unload that lands in that window would + * otherwise leave a live engine bound to a dead world. Only an unload Iris did not drive is covered: + * an Iris-driven unload carries a boundary and closes the generator in its own phase. The close is + * CAS-idempotent, so a redundant call returns the in-flight completion. + */ + private void closeUntrackedGenerator(World world) { + PlatformChunkGenerator generator = IrisToolbelt.access(world); + if (generator == null || generator.isClosing()) { + return; + } + IrisLogging.debug("EngineSVC: closing untracked generator for unloaded world " + world.getName()); + CompletableFuture close = generator.closeAsync(); + if (close == null) { + return; + } + close.whenComplete((ignored, failure) -> { + if (failure != null) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + reportFailure("Failed to close untracked generator for " + world.getName(), cause); + } + }); } @EventHandler @@ -235,9 +265,12 @@ public final class IrisEngineSVC implements IrisService { } } - private void remove(World world, CompletionStage unloadBoundary) { + /** + * @return true when a registration was found and its close was reserved + */ + private boolean remove(World world, CompletionStage unloadBoundary) { if (world == null) { - return; + return false; } Registered registered; @@ -250,10 +283,12 @@ public final class IrisEngineSVC implements IrisService { closing = reserveClose(registered); } } - if (closing != null) { - phases.closing(world); - deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary); + if (closing == null) { + return false; } + phases.closing(world); + deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary); + return true; } private void deferCloseUntilWorldUnload( diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java new file mode 100644 index 000000000..8e9216bec --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapWorldStorageContractTest.java @@ -0,0 +1,194 @@ +package art.arcane.iris; + +import art.arcane.iris.core.lifecycle.BukkitStartupPaths; +import art.arcane.iris.core.lifecycle.MissingWorldStorageLog; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * The server enumerates levels from disk and CraftBukkit falls back to the vanilla generator whenever the + * plugin cannot supply one, so a world with storage Iris cannot use has to stop startup before any level is + * created. A world with no storage at all is only reported. + */ +public class IrisBootstrapWorldStorageContractTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Before + public void clearOrphanReports() { + MissingWorldStorageLog.reset(); + } + + @Test + public void startupIsRefusedWhenAConfiguredWorldLostItsPackSnapshot() throws Exception { + Path serverRoot = temporaryFolder.newFolder("broken-server").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/iris/orphan2/region")); + Files.writeString(serverRoot.resolve("world/dimensions/iris/orphan2/region/r.0.0.mca"), "terrain"); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot, "world_iris_orphan2"); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot))); + + assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_orphan2")); + assertTrue(failure.getMessage(), + failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/iris/orphan2") + .toString())); + } + + /** + * Deleting a live world's folder and letting the server save it back leaves a data/ skeleton with no + * regions and no iris/ directory. It owns nothing, so refusing to boot over it only strands the server. + */ + @Test + public void startupContinuesForAServerRewrittenHuskAndReportsItOnce() throws Exception { + Path serverRoot = temporaryFolder.newFolder("husk-server").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/iris/husk/data/paper")); + Files.writeString(serverRoot.resolve("world/dimensions/iris/husk/data/paper/level_overrides.dat"), "x"); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot, "world_iris_husk"); + + IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)); + + assertTrue(MissingWorldStorageLog.hasWarned("world_iris_husk")); + } + + /** + * Both classifications of a hot-deleted world's husk stop the boot: UNUSABLE trips Iris' fail-closed + * guard, and EMPTY excludes it from the dimension registry, which trips Paper's interactive world + * migration gate. The husk has to leave the dimensions tree before either can happen. + */ + @Test + public void aHotDeletedWorldsHuskIsMovedOutOfTheDimensionsTree() throws Exception { + Path serverRoot = temporaryFolder.newFolder("husk-quarantine").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/iris/husk/data/paper")); + Files.writeString(serverRoot.resolve("world/dimensions/iris/husk/data/paper/level_overrides.dat"), "x"); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot, "world_iris_husk"); + List warnings = new ArrayList<>(); + + IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add); + IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)); + + assertFalse(Files.exists(serverRoot.resolve("world/dimensions/iris/husk"))); + assertEquals(1, warnings.size()); + assertTrue(warnings.getFirst(), + warnings.getFirst().contains("/iris remove world=world_iris_husk delete=true")); + assertTrue(MissingWorldStorageLog.hasWarned("world_iris_husk")); + } + + /** + * Paper enumerates the dimensions tree from disk, so a husk whose bukkit.yml entry is already gone stops + * the boot exactly the same way. The sweep is over the tree, not over the configuration. + */ + @Test + public void aHuskWithNoBukkitConfigurationEntryIsStillMovedOut() throws Exception { + Path serverRoot = temporaryFolder.newFolder("husk-unconfigured").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/iris/husk/data/minecraft")); + Files.writeString(serverRoot.resolve("world/dimensions/iris/husk/data/minecraft/raids.dat"), "x"); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot); + List warnings = new ArrayList<>(); + + IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add); + + assertFalse(Files.exists(serverRoot.resolve("world/dimensions/iris/husk"))); + assertEquals(1, warnings.size()); + } + + @Test + public void aWorldWithRealDataIsNeverQuarantinedAndStillStopsStartup() throws Exception { + Path serverRoot = temporaryFolder.newFolder("husk-with-data").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/iris/kept/region")); + Files.writeString(serverRoot.resolve("world/dimensions/iris/kept/region/r.0.0.mca"), "terrain"); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot, "world_iris_kept"); + List warnings = new ArrayList<>(); + + IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add); + + assertTrue(Files.isDirectory(serverRoot.resolve("world/dimensions/iris/kept"))); + assertTrue(warnings.isEmpty()); + assertThrows( + IllegalStateException.class, + () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot))); + } + + /** + * The engine rebuilds iris/engine-data under a world folder the server itself recreated during save-all, + * so an iris/ directory with no pack snapshot is the ambiguous state, not a worthless one. + */ + @Test + public void anIrisMarkerWithNoPackSnapshotStillFailsClosed() throws Exception { + Path serverRoot = temporaryFolder.newFolder("husk-marker").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/iris/marked/iris/engine-data")); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot, "world_iris_marked"); + List warnings = new ArrayList<>(); + + IrisBootstrap.quarantineWorthlessHusks(startupPaths(serverRoot), warnings::add); + + assertTrue(Files.isDirectory(serverRoot.resolve("world/dimensions/iris/marked/iris/engine-data"))); + assertTrue(warnings.isEmpty()); + assertThrows( + IllegalStateException.class, + () -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot))); + } + + @Test + public void startupContinuesForAWorldWhoseFolderIsGoneAndReportsItOnce() throws Exception { + Path serverRoot = temporaryFolder.newFolder("orphan-server").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/iris")); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot, "world_iris_orphan1"); + + IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)); + + assertTrue(MissingWorldStorageLog.hasWarned("world_iris_orphan1")); + } + + @Test + public void startupContinuesForHealthyWorlds() throws Exception { + Path serverRoot = temporaryFolder.newFolder("healthy-server").toPath(); + Files.createDirectories(serverRoot.resolve("world/dimensions/iris/moon/iris/pack")); + writeServerProperties(serverRoot); + writeBukkitWorlds(serverRoot, "world_iris_moon"); + + IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)); + + assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon")); + } + + private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception { + return BukkitStartupPaths.resolve(serverRoot, new String[0]); + } + + private static void writeServerProperties(Path serverRoot) throws Exception { + Files.writeString(serverRoot.resolve("server.properties"), "level-name=world\n", StandardCharsets.UTF_8); + } + + private static void writeBukkitWorlds(Path serverRoot, String... configuredWorldNames) throws Exception { + File configuration = serverRoot.resolve("bukkit.yml").toFile(); + YamlConfiguration yaml = new YamlConfiguration(); + for (String configuredWorldName : configuredWorldNames) { + yaml.set("worlds." + configuredWorldName + ".generator", "Iris:overworld"); + } + yaml.save(configuration); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java new file mode 100644 index 000000000..362297596 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticLogLevelTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris; + +import art.arcane.iris.spi.LogLevel; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Level; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Core states a severity on every message it logs and the modded adapters honour it. On Bukkit the same + * message used to become a coloured console line, which the server logs at INFO, so no core warning ever + * appeared in a WARN-level scan of logs/latest.log - including the orphaned-world-storage reports the + * bootstrap replays there specifically for operators to find. + */ +public class IrisDiagnosticLogLevelTest { + @Test + public void coreDiagnosticsKeepTheirSeverity() { + assertEquals(Level.WARNING, Iris.diagnosticLevel(LogLevel.WARN)); + assertEquals(Level.SEVERE, Iris.diagnosticLevel(LogLevel.ERROR)); + } + + @Test + public void informationalAndDebugMessagesStayOnTheConsolePath() { + assertNull(Iris.diagnosticLevel(LogLevel.INFO)); + assertNull(Iris.diagnosticLevel(LogLevel.DEBUG)); + } + + /** + * Console sender output reaches the terminal but not the instance's logs/latest.log, which is the only + * log most operators read after the fact. A handful of lifecycle lines go to the plugin logger instead. + */ + @Test + public void lifecycleNoticesReachThePluginLoggerAtInfo() { + assertEquals(Level.INFO, Iris.diagnosticLevel(LogLevel.NOTICE)); + } + + /** + * A warning raised by the adapter is the same kind of thing as a warning raised by core. Routing one + * through the plugin logger and the other through the console sender makes the level depend on which + * side of the SPI the call happened to be written on. + */ + @Test + public void adapterSideWarningsCarryTheSameSeverityAsCoreWarnings() throws Exception { + String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")); + + String warn = method(source, "public static void warn(String format, Object... objs)"); + assertTrue(warn, warn.contains("diagnostic(Level.WARNING")); + assertFalse(warn, warn.contains("msg(")); + + String error = method(source, "public static void error(String format, Object... objs)"); + assertTrue(error, error.contains("diagnostic(Level.SEVERE")); + assertFalse(error, error.contains("msg(")); + } + + @Test + public void theCoreLogBridgeRoutesDiagnosticsToThePluginLogger() throws Exception { + String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")); + String bridge = method(source, "private static void bridgeLog(LogLevel level, String message)"); + + assertTrue(bridge, bridge.contains("diagnosticLevel(target)")); + assertTrue(bridge, bridge.contains("diagnostic(diagnostic, message)")); + assertTrue(source.contains("plugin.getLogger().log(level, line)")); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("method not found: " + signature, start >= 0); + int open = source.indexOf('{', start); + int depth = 0; + for (int i = open; i < source.length(); i++) { + char c = source.charAt(i); + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + return source.substring(start, i + 1); + } + } + } + throw new AssertionError("unterminated method: " + signature); + } +} 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 7496dc732..a98eb68b1 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 @@ -94,15 +94,97 @@ public class IrisWorldGeneratorResolverTest { } @Test - public void paperStartupAliasResolvesToCanonicalRuntimeKey() { + public void paperStartupAliasResolvesToCanonicalRuntimeKey() throws Exception { + File levelRoot = ownedLevelRoot("startup-alias", "moon"); + assertEquals( new NamespacedKey("iris", "moon"), - IrisWorldGeneratorResolver.configuredWorldKey("world_iris_moon", "world") + IrisWorldGeneratorResolver.configuredWorldKey("world_iris_moon", "world", levelRoot) ); assertEquals( new NamespacedKey("iris", "moon"), - IrisWorldGeneratorResolver.configuredWorldKey("moon", "world") + IrisWorldGeneratorResolver.configuredWorldKey("moon", "world", levelRoot) ); + assertEquals( + NamespacedKey.minecraft("overworld"), + IrisWorldGeneratorResolver.configuredWorldKey("world", "world", levelRoot) + ); + } + + @Test + public void multiverseRuntimeKeyedNameResolvesToTheStoredIrisWorld() throws Exception { + File levelRoot = ownedLevelRoot("runtime-keyed", "moon"); + + assertEquals( + "mv load names an Iris world iris_ because its keyed creator refuses the namespace", + new NamespacedKey("iris", "moon"), + IrisWorldGeneratorResolver.configuredWorldKey("iris_moon", "world", levelRoot) + ); + } + + @Test + public void runtimeKeyedNameWithoutStorageKeepsItsLiteralKey() throws Exception { + File levelRoot = ownedLevelRoot("runtime-keyed-missing", "elsewhere"); + + assertEquals( + new NamespacedKey("iris", "iris_moon"), + IrisWorldGeneratorResolver.configuredWorldKey("iris_moon", "world", levelRoot) + ); + } + + @Test + public void literalWorldStorageWinsOverTheRuntimeKeyedReading() throws Exception { + File levelRoot = ownedLevelRoot("runtime-keyed-collision", "moon"); + assertTrue(new File(levelRoot, "dimensions/iris/iris_moon").mkdirs()); + + assertEquals( + "a world genuinely created as iris_moon keeps its own identity", + new NamespacedKey("iris", "iris_moon"), + IrisWorldGeneratorResolver.configuredWorldKey("iris_moon", "world", levelRoot) + ); + } + + @Test + public void levelNamedIrisStillResolvesItsOwnStartupNames() throws Exception { + File levelRoot = temporaryFolder.newFolder("level-named-iris", "iris"); + assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs()); + + assertEquals( + new NamespacedKey("iris", "moon"), + IrisWorldGeneratorResolver.configuredWorldKey("iris_iris_moon", "iris", levelRoot) + ); + } + + /** + * A refusal has to name the world the admin typed. The generation guard keeps the storage-existence + * check that stops a genuine iris_moon world being mis-mapped; the message must not, or an orphan is + * described as iris:iris_orphan1 and the remedy names a world that never existed. + */ + @Test + public void refusalMessagesNameTheRuntimeKeyedWorldWithoutItsStorage() { + assertEquals( + new NamespacedKey("iris", "orphan1"), + IrisWorldGeneratorResolver.messageWorldKey("iris_orphan1", "world") + ); + assertEquals( + new NamespacedKey("iris", "moon"), + IrisWorldGeneratorResolver.messageWorldKey("world_iris_moon", "world") + ); + assertEquals( + "a world genuinely created as iris_moon keeps its own identity", + new NamespacedKey("iris", "iris_moon"), + IrisWorldGeneratorResolver.messageWorldKey("world_iris_iris_moon", "world") + ); + assertEquals( + NamespacedKey.minecraft("overworld"), + IrisWorldGeneratorResolver.messageWorldKey("world", "world") + ); + } + + private File ownedLevelRoot(String scope, String worldKey) throws Exception { + File levelRoot = temporaryFolder.newFolder(scope, "world"); + assertTrue(new File(levelRoot, "dimensions/iris/" + worldKey).mkdirs()); + return levelRoot; } @Test diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java index 03adff440..cfadd6843 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java @@ -76,7 +76,7 @@ public class IrisApiWiringContractTest { assertBefore(add, "registered = true;", "phases.ready(world)"); String remove = method(source, - "private void remove(World world, CompletionStage unloadBoundary)"); + "private boolean remove(World world, CompletionStage unloadBoundary)"); assertBefore(remove, "registered = worlds.remove(world)", "phases.closing(world)"); assertBefore(remove, "phases.closing(world)", "deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary)"); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java index 2496a3f1a..f8b0dfe0b 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java @@ -49,7 +49,7 @@ public class IrisEngineLifecycleContractTest { public void registrationRetryWaitsAsynchronouslyForClose() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource"))); String add = method(source, "private void add(World world)"); - String remove = method(source, "private void remove(World world, CompletionStage unloadBoundary)"); + String remove = method(source, "private boolean remove(World world, CompletionStage unloadBoundary)"); String completeClose = method(source, "private void completeClose("); String retry = method(source, "private void retryRegistrationAfterClose("); String invokeClose = method(source, "private CompletableFuture invokeGeneratorClose()"); @@ -73,7 +73,7 @@ public class IrisEngineLifecycleContractTest { public void worldUnloadDefersGeneratorCloseUntilTheRawBoundaryCompletesTrue() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource"))); String handler = method(source, "public void onWorldUnload(WorldUnloadEvent event)"); - String remove = method(source, "private void remove(World world, CompletionStage unloadBoundary)"); + String remove = method(source, "private boolean remove(World world, CompletionStage unloadBoundary)"); String defer = method(source, "private void deferCloseUntilWorldUnload("); assertBefore(handler, "WorldUnloadBoundaryRegistry.claim(", "remove(world, unloadBoundary)"); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineSVCUntrackedUnloadTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineSVCUntrackedUnloadTest.java new file mode 100644 index 000000000..e014d930a --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineSVCUntrackedUnloadTest.java @@ -0,0 +1,59 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.engine.platform.BukkitChunkGenerator; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.event.world.WorldUnloadEvent; +import org.junit.Test; + +import java.util.concurrent.CompletableFuture; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Multiverse unloads a world through {@code Bukkit.unloadWorld}, which carries no Iris unload boundary. + * {@code IrisEngineSVC} closes the engine of every world it registered, but registration is skipped while + * a previous generator for the same identity is still closing, and an unload landing in that window used + * to leave a live engine bound to a dead world. + */ +public class IrisEngineSVCUntrackedUnloadTest { + @Test + public void externalUnloadClosesAnIrisGeneratorTheServiceNeverRegistered() { + BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class); + when(generator.closeAsync()).thenReturn(CompletableFuture.completedFuture(null)); + + new IrisEngineSVC().onWorldUnload(new WorldUnloadEvent(irisWorld("mvtest", generator))); + + verify(generator).closeAsync(); + } + + @Test + public void externalUnloadOfANonIrisWorldClosesNothing() { + World world = mock(World.class); + when(world.getName()).thenReturn("plots"); + when(world.getKey()).thenReturn(new NamespacedKey("minecraft", "plots")); + + new IrisEngineSVC().onWorldUnload(new WorldUnloadEvent(world)); + } + + @Test + public void externalUnloadDoesNotReenterAGeneratorThatIsAlreadyClosing() { + BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class); + when(generator.isClosing()).thenReturn(true); + + new IrisEngineSVC().onWorldUnload(new WorldUnloadEvent(irisWorld("closing", generator))); + + verify(generator, never()).closeAsync(); + } + + private static World irisWorld(String key, BukkitChunkGenerator generator) { + World world = mock(World.class); + when(world.getName()).thenReturn("world_iris_" + key); + when(world.getKey()).thenReturn(new NamespacedKey("iris", key)); + when(world.getGenerator()).thenReturn(generator); + return world; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisLog.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisLog.java index 475d7137d..31b2ef6d0 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisLog.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisLog.java @@ -35,9 +35,13 @@ public final class ModdedIrisLog { LogLevel target = level == null ? LogLevel.INFO : level; switch (target) { case DEBUG -> debug(message); - case INFO -> info(message); case WARN -> warn(message); case ERROR -> error(message); + // INFO, NOTICE, and any level added later. This is a switch statement, so the compiler does not + // check it for exhaustiveness and a missing arm would drop the message silently. The modded + // loaders have one logger, so a lifecycle notice is already in the file the server writes; the + // level only differs on Bukkit, where INFO goes to a console sender instead. + default -> info(message); } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedIrisLogLevelCoverageTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedIrisLogLevelCoverageTest.java new file mode 100644 index 000000000..19a317138 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedIrisLogLevelCoverageTest.java @@ -0,0 +1,27 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +/** + * The level router is a switch statement, which the compiler does not check for exhaustiveness. A level added + * to {@link art.arcane.iris.spi.LogLevel} with no arm here would compile and then drop every message at that + * level without a trace, so the fallback arm is what keeps a new level visible on the modded loaders. + */ +public class ModdedIrisLogLevelCoverageTest { + @Test + public void everyLogLevelReachesTheLoggerIncludingOnesAddedLater() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"), + "art/arcane/iris/modded/ModdedIrisLog.java")); + int router = source.indexOf("public static void log(LogLevel level, String message)"); + assertTrue("log(LogLevel, String) not found", router >= 0); + + String body = source.substring(router, source.indexOf("public static void debug(", router)); + assertTrue(body, body.contains("default -> info(message);")); + } +} diff --git a/core/build.gradle b/core/build.gradle index 38ae052c7..afc991214 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -117,7 +117,7 @@ dependencies { testImplementation('org.mockito:mockito-core:5.23.0') testImplementation(libs.paper.api) testRuntimeOnly(libs.paper.api) - testRuntimeOnly(libs.multiverseCore) + testImplementation(libs.multiverseCore) } tasks.named('test').configure { diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt index b49452462..73b462fc2 100644 --- a/core/purity-allowlist.txt +++ b/core/purity-allowlist.txt @@ -24,6 +24,7 @@ art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java art/arcane/iris/core/lifecycle/WorldsProviderBackend.java art/arcane/iris/core/link/ExternalDataProvider.java art/arcane/iris/core/link/MultiverseCoreLink.java +art/arcane/iris/core/link/MultiverseGuardListener.java art/arcane/iris/core/link/WorldEditLink.java art/arcane/iris/core/link/data/CraftEngineDataProvider.java art/arcane/iris/core/link/data/EcoItemsDataProvider.java @@ -66,6 +67,7 @@ art/arcane/iris/core/service/JigsawStudioBoundsRenderer.java art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java art/arcane/iris/core/service/JigsawStudioService.java art/arcane/iris/core/service/JigsawStudioToolCodec.java +art/arcane/iris/core/service/MultiverseSVC.java art/arcane/iris/core/service/ObjectSVC.java art/arcane/iris/core/service/ObjectStudioSaveService.java art/arcane/iris/core/service/PackDownloadProgressReporter.java diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java index 090510394..b6afd8c50 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java @@ -17,6 +17,7 @@ import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.Properties; +import java.util.stream.Stream; public final class IrisWorldStorage { private static final String IRIS_NAMESPACE = "iris"; @@ -325,6 +326,29 @@ public final class IrisWorldStorage { return target.toFile(); } + /** + * True when the level root holds at least one Iris-managed world directory. Used by the failure paths + * that have to decide whether letting the server continue would put vanilla terrain into Iris storage. + */ + public static boolean hasManagedWorldStorage(File levelRoot) { + if (levelRoot == null) { + return false; + } + Path namespace = levelRoot.toPath() + .toAbsolutePath() + .normalize() + .resolve("dimensions") + .resolve(IRIS_NAMESPACE); + if (!Files.isDirectory(namespace, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + try (Stream entries = Files.list(namespace)) { + return entries.anyMatch(entry -> Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)); + } catch (IOException unreadable) { + return false; + } + } + public static boolean isExistingManagedDimensionRoot(File levelRoot, NamespacedKey key) { try { Path target = requireSafeManagedDimensionRoot(levelRoot, key).toPath(); diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java index 0b7dcbd4d..35815be4d 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java @@ -1,5 +1,6 @@ package art.arcane.iris.core; +import art.arcane.iris.core.lifecycle.MissingWorldStorageLog; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.service.StudioSVC; @@ -32,12 +33,16 @@ import java.nio.file.StandardOpenOption; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; public class IrisWorlds { private static final String IRIS_GENERATOR_NAME = "iris"; private static final String IRIS_GENERATOR_PREFIX = "iris:"; + private static final String IRIS_NAMESPACE = "iris"; private static final AtomicCache cache = new AtomicCache<>(); + private static final Set UNUSABLE_STORAGE_REPORTED = ConcurrentHashMap.newKeySet(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Type TYPE = TypeToken.getParameterized(KMap.class, String.class, String.class).getType(); private final Path levelRoot; @@ -49,15 +54,33 @@ public class IrisWorlds { this.levelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize(); registryFile = registryFile(this.levelRoot); this.worlds = new KMap<>(); - worlds.forEach((identity, type) -> this.worlds.put(WorldIdentity.parse(identity).toString(), type)); + // One unreadable entry drops itself. This runs behind a static cache whose callers all dereference + // the result immediately, so anything thrown here stops Iris from enabling for every world. + worlds.forEach((identity, type) -> { + try { + this.worlds.put(WorldIdentity.parse(identity).toString(), type); + } catch (IllegalArgumentException unreadable) { + IrisLogging.warn("Dropping unreadable worlds.json entry %s: %s", identity, unreadable.getMessage()); + dirty = true; + } + }); readBukkitWorlds(this.levelRoot).forEach((name, type) -> put0( IrisWorldStorage.keyFromConfiguredWorldName(name, this.levelRoot.getFileName().toString()).toString(), type)); save(); } + /** + * The world registry, never null. + *

+ * A failure here used to be swallowed into a null return, and every caller dereferences the result + * immediately, so a single unusable world folder turned into an NPE that stopped Iris from enabling - + * and a server whose Iris worlds then generated vanilla terrain over their own region files. Per-world + * storage problems are isolated in {@link #clean()} and {@link #loadDimension(String, String)}; anything + * that gets past those is a real failure and is raised, not hidden. + */ public static IrisWorlds get() { - return cache.aquire(() -> { + return cache.aquireOnceOrThrow(() -> { Path levelRoot = IrisWorldStorage.levelRoot().toPath().toAbsolutePath().normalize(); File file = registryFile(levelRoot).toFile(); if (!file.exists()) { @@ -69,8 +92,8 @@ public class IrisWorlds { KMap worlds = GSON.fromJson(json, TYPE); return new IrisWorlds(levelRoot, Objects.requireNonNullElseGet(worlds, KMap::new)); } catch (Throwable e) { - IrisLogging.error("Failed to load worlds.json for level root " + levelRoot + "!"); - e.printStackTrace(); + IrisLogging.error("Failed to read worlds.json for level root " + levelRoot + + "; rebuilding it from bukkit.yml."); IrisLogging.reportError(e); } @@ -138,6 +161,14 @@ public class IrisWorlds { .filter(Objects::nonNull); } + /** + * The dimension one registered world generates from, or null when its pack snapshot cannot supply it. + * Same resolution {@link #getDimensions()} uses, for callers that hold a single world identity. + */ + public IrisDimension getDimension(String worldIdentity, String id) { + return loadDimension(worldIdentity, id); + } + public Stream getDimensions() { return getWorlds() .entrySet() @@ -146,6 +177,14 @@ public class IrisWorlds { .filter(Objects::nonNull); } + /** + * Drops registry entries whose pack snapshot no longer backs them. + *

+ * A world whose storage is present but unusable is kept and reported instead of dropped: the entry is + * what lets {@code /iris remove} find the world, and dropping it would not stop the server from loading + * the folder anyway. It is excluded from {@link #getDimensions()} so one broken world cannot fail the + * whole registry - that failure used to propagate out of the constructor and null out {@link #get()}. + */ public synchronized void clean() { boolean removed = worlds.entrySet().removeIf(entry -> { try { @@ -154,6 +193,9 @@ public class IrisWorlds { || !new File(packRoot.get(), "dimensions/" + entry.getValue() + ".json").exists(); } catch (IllegalArgumentException e) { return true; + } catch (IllegalStateException e) { + warnUnusableStorage(entry.getKey(), e); + return false; } }); dirty = dirty || removed; @@ -232,27 +274,45 @@ public class IrisWorlds { KMap result = new KMap<>(); for (Map.Entry entry : Objects.requireNonNull(configuredWorlds, "configuredWorlds").entrySet()) { String configuredWorldName = entry.getKey(); - NamespacedKey worldKey = IrisWorldStorage.keyFromConfiguredWorldName( - configuredWorldName, - levelNamePath.toString()); - if (!IrisWorldStorage.configuredWorldName(worldKey, levelNamePath.toString()) - .equals(configuredWorldName)) { + NamespacedKey worldKey; + try { + worldKey = IrisWorldStorage.keyFromConfiguredWorldName( + configuredWorldName, + levelNamePath.toString()); + if (!IrisWorldStorage.configuredWorldName(worldKey, levelNamePath.toString()) + .equals(configuredWorldName)) { + continue; + } + } catch (IllegalArgumentException notAnIrisWorldName) { + // A bukkit.yml name that has no Iris identity is somebody else's world, never this + // registry's, and must not stop the whole registry from being built. continue; } Path worldContainer = root.getParent(); if (worldContainer == null) { throw new IllegalArgumentException("Selected level root has no world container: " + root); } + boolean storagePresent; try { - if (IrisWorldStorage.frozenDimensionRoot( + storagePresent = IrisWorldStorage.frozenDimensionRoot( worldContainer.toFile(), root.toFile(), configuredWorldName, worldKey - ).isPresent()) { - result.put(configuredWorldName, entry.getValue()); - } - } catch (IllegalStateException ignored) { + ).isPresent(); + } catch (IllegalStateException unusableStorage) { + storagePresent = false; + } + if (storagePresent) { + result.put(configuredWorldName, entry.getValue()); + continue; + } + // Vanilla slots are absent on every server that never created them; only an Iris world that + // bukkit.yml still points at is an orphan worth reporting. + if (IRIS_NAMESPACE.equals(worldKey.getNamespace())) { + MissingWorldStorageLog.warnOnce( + configuredWorldName, + root.resolve("dimensions").resolve(IRIS_NAMESPACE).resolve(worldKey.getKey())); } } return result; @@ -310,8 +370,24 @@ public class IrisWorlds { return dimensionRoot.map(IrisWorldStorage::requireFrozenPackRoot); } + private static void warnUnusableStorage(String worldIdentity, IllegalStateException failure) { + if (!UNUSABLE_STORAGE_REPORTED.add(worldIdentity)) { + return; + } + IrisLogging.error("Iris world %s has unusable world storage and is excluded: %s", + worldIdentity, failure.getMessage()); + IrisLogging.error("Restore its iris/pack snapshot, or drop the world with /iris remove world=%s delete=true.", + worldIdentity); + } + private IrisDimension loadDimension(String worldIdentity, String id) { - File pack = packRoot(worldIdentity).orElse(null); + File pack; + try { + pack = packRoot(worldIdentity).orElse(null); + } catch (IllegalStateException unusableStorage) { + warnUnusableStorage(worldIdentity, unusableStorage); + return null; + } IrisDimension dimension = pack == null ? null : IrisData.get(pack).getDimensionLoader().load(id); if (dimension == null) { dimension = IrisData.loadAnyDimension(id, null); 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 05de7503b..b6e4ef496 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -986,12 +986,11 @@ public class ServerConfigurator { if (INMS.get().supportsDataPacks()) { - IrisLogging.error("============================================================================"); + // Three sentences, no rules: a separator carries the record's severity too, so a box drawn + // out of equals signs became three more [SEVERE] lines saying nothing. IrisLogging.error(C.ITALIC + "You need to restart your server to properly generate custom biomes."); IrisLogging.error(C.ITALIC + "By continuing, Iris will use backup biomes in place of the custom biomes."); - IrisLogging.error("----------------------------------------------------------------------------"); - IrisLogging.error(C.UNDERLINE + "IT IS HIGHLY RECOMMENDED YOU RESTART THE SERVER BEFORE GENERATING!"); - IrisLogging.error("============================================================================"); + IrisLogging.error(C.UNDERLINE + "Restart the server before generating."); for (Player i : Bukkit.getOnlinePlayers()) { if (i.isOp() || i.hasPermission("iris.all")) { diff --git a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java index 45b6c4962..ec4517cc4 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java +++ b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java @@ -99,7 +99,7 @@ public final class GuiHost { desktop.setQuitHandler((event, response) -> cancelDesktopQuit(response)); } catch (Throwable error) { IrisLogging.reportError(error); - IrisLogging.warn("Unable to install the Iris desktop quit guard; use the server stop command instead of macOS Quit"); + IrisLogging.info("Unable to install the Iris desktop quit guard; use the server stop command instead of macOS Quit"); } } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java index f2d9b307c..573c08f83 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Predicate; +import java.util.stream.Stream; public final class BukkitWorldConfiguration { private static final String DEFAULT_WORLD_CONTAINER = "."; @@ -113,58 +114,12 @@ public final class BukkitWorldConfiguration { return List.of(); } synchronized (MUTATION_LOCK) { - YamlConfiguration configuration = load(requiredConfigurationFile); - Object rawWorlds = configuration.get("worlds"); - ConfigurationSection worlds = configuration.getConfigurationSection("worlds"); - if (rawWorlds != null && worlds == null) { - throw new IOException("bukkit.yml worlds entry is not a section."); - } - if (worlds == null) { - return List.of(); - } - - List configuredNames = new ArrayList<>(worlds.getKeys(false)); - configuredNames.sort(Comparator.naturalOrder()); Map bindings = new LinkedHashMap<>(); - for (String configuredName : configuredNames) { - Object rawWorld = worlds.get(configuredName); - ConfigurationSection world = worlds.getConfigurationSection(configuredName); - if (rawWorld != null && world == null) { - throw new IOException("bukkit.yml world entry \"" + configuredName + "\" is not a section."); - } - if (world == null || !world.getKeys(false).contains("generator")) { - continue; - } - Object rawGenerator = world.get("generator"); - if (!(rawGenerator instanceof String generator)) { - throw new IOException("bukkit.yml generator for world \"" + configuredName - + "\" is not a string."); - } - String configuredGenerator = generator.trim(); - if (!configuredGenerator.equalsIgnoreCase("Iris") - && !configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5)) { - continue; - } - - NamespacedKey namespacedKey; - try { - namespacedKey = IrisWorldStorage.keyFromConfiguredWorldName( - configuredName, - requiredLevelName - ); - } catch (IllegalArgumentException failure) { - throw new IOException("bukkit.yml contains an invalid Iris world name \"" - + configuredName + "\".", failure); - } - if (!"iris".equals(namespacedKey.getNamespace())) { - continue; - } - if (!configuredName.equals(IrisWorldStorage.configuredWorldName( - namespacedKey, - requiredLevelName - ))) { - continue; - } + for (IrisWorldCandidate candidate : readIrisWorldCandidates( + load(requiredConfigurationFile), + requiredLevelName + )) { + String configuredName = candidate.configuredWorldName(); Path worldContainer = requiredLevelRoot.getParent(); if (worldContainer == null) { throw new IOException("Selected level root has no world container: " + requiredLevelRoot); @@ -175,18 +130,21 @@ public final class BukkitWorldConfiguration { worldContainer.toFile(), requiredLevelRoot.toFile(), configuredName, - namespacedKey + candidate.worldKey() ).isPresent(); } catch (IllegalStateException failure) { storagePresent = false; } if (!storagePresent) { + MissingWorldStorageLog.warnOnce( + configuredName, + expectedDimensionRoot(requiredLevelRoot, candidate.worldKey())); continue; } - String dimension = selectedIrisDimension(configuredGenerator, configuredName); + String dimension = selectedIrisDimension(candidate.configuredGenerator(), configuredName); WorldSlotKey worldKey = new WorldSlotKey( - namespacedKey.getNamespace(), - namespacedKey.getKey() + candidate.worldKey().getNamespace(), + candidate.worldKey().getKey() ); IrisGeneratorBinding binding = new IrisGeneratorBinding(configuredName, worldKey, dimension); IrisGeneratorBinding previous = bindings.putIfAbsent(worldKey, binding); @@ -199,6 +157,228 @@ public final class BukkitWorldConfiguration { } } + /** + * Classifies the world storage behind every Iris entry in bukkit.yml. + *

+ * {@link #readIrisGeneratorBindings} answers "which worlds can Iris bind", which deliberately drops + * anything without storage. Startup needs the opposite view: a world whose folder is on disk but whose + * frozen pack snapshot is gone still gets loaded by the server, and if Iris cannot supply a generator + * for it the server writes vanilla terrain into Iris region files. That state has to be found before + * any level loads, so it is reported here rather than discovered at world-generation time. + */ + public static List auditIrisWorldStorage( + File configurationFile, + String levelName, + Path levelRoot + ) throws IOException { + File requiredConfigurationFile = Objects.requireNonNull(configurationFile, "configurationFile"); + String requiredLevelName = requireName(levelName, "Level name"); + Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot") + .toAbsolutePath() + .normalize(); + Path configurationPath = requiredConfigurationFile.toPath(); + if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) { + return List.of(); + } + Path worldContainer = requiredLevelRoot.getParent(); + if (worldContainer == null) { + throw new IOException("Selected level root has no world container: " + requiredLevelRoot); + } + synchronized (MUTATION_LOCK) { + List entries = new ArrayList<>(); + for (IrisWorldCandidate candidate : readIrisWorldCandidates( + load(requiredConfigurationFile), + requiredLevelName + )) { + entries.add(classifyStorage(worldContainer, requiredLevelRoot, candidate)); + } + return List.copyOf(entries); + } + } + + private static IrisWorldStorageEntry classifyStorage( + Path worldContainer, + Path levelRoot, + IrisWorldCandidate candidate + ) { + String configuredName = candidate.configuredWorldName(); + NamespacedKey worldKey = candidate.worldKey(); + WorldSlotKey slotKey = new WorldSlotKey(worldKey.getNamespace(), worldKey.getKey()); + Path expectedRoot = expectedDimensionRoot(levelRoot, worldKey); + File dimensionRoot; + try { + dimensionRoot = IrisWorldStorage.frozenDimensionRoot( + worldContainer.toFile(), + levelRoot.toFile(), + configuredName, + worldKey + ).orElse(null); + } catch (IllegalStateException unusable) { + // The server only loads a level it can see as a directory, so an entry with no directory at + // either candidate path is an orphan, not a world that is about to generate. + return hasDimensionDirectory(worldContainer, levelRoot, configuredName, worldKey) + ? new IrisWorldStorageEntry(configuredName, slotKey, expectedRoot, + IrisWorldStorageState.UNUSABLE, unusable.getMessage()) + : new IrisWorldStorageEntry(configuredName, slotKey, expectedRoot, + IrisWorldStorageState.MISSING, null); + } + if (dimensionRoot == null) { + return new IrisWorldStorageEntry(configuredName, slotKey, expectedRoot, + IrisWorldStorageState.MISSING, null); + } + Path resolvedRoot = dimensionRoot.toPath().toAbsolutePath().normalize(); + try { + IrisWorldStorage.requireFrozenPackRoot(dimensionRoot); + } catch (IllegalStateException unusablePack) { + // Only a folder that owns something can be corrupted by a vanilla generator writing into it. + return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot, + hasWorldData(resolvedRoot) + ? IrisWorldStorageState.UNUSABLE + : IrisWorldStorageState.EMPTY, + unusablePack.getMessage()); + } + return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot, + IrisWorldStorageState.PRESENT, null); + } + + /** + * True when the world folder holds something a vanilla generator could overwrite: an {@code iris/} + * directory marking it as an Iris world whose pack snapshot broke, or stored chunk data. + *

+ * Deleting a loaded world's folder and letting the server save the level back recreates a small + * {@code data/} skeleton with no regions and no {@code iris/} directory. That husk owns nothing, so + * refusing to boot over it only strands the server behind a manual delete. + */ + private static boolean hasWorldData(Path worldRoot) { + if (Files.isDirectory(worldRoot.resolve("iris"), LinkOption.NOFOLLOW_LINKS)) { + return true; + } + for (String directory : new String[]{"region", "entities", "poi", "mantle"}) { + if (hasEntries(worldRoot.resolve(directory))) { + return true; + } + } + return false; + } + + private static boolean hasEntries(Path directory) { + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + try (Stream entries = Files.list(directory)) { + return entries.findAny().isPresent(); + } catch (IOException unreadable) { + // An unreadable directory is not proof that there is nothing to lose. + return true; + } + } + + private static boolean hasDimensionDirectory( + Path worldContainer, + Path levelRoot, + String configuredWorldName, + NamespacedKey worldKey + ) { + if (occupiesDimensionPath(levelRoot, expectedDimensionRoot(levelRoot, worldKey))) { + return true; + } + Path configuredLevelRoot = worldContainer.resolve(configuredWorldName).normalize(); + return occupiesDimensionPath(configuredLevelRoot, expectedDimensionRoot(configuredLevelRoot, worldKey)); + } + + /** + * True when something the server could load sits at the dimension path: a real directory, or a symbolic + * link on any segment below the level root. + *

+ * {@link IrisWorldStorage} refuses to resolve through a link, so a linked path is storage Iris cannot use, + * not storage that is not there. Calling it missing tells the operator to restore a folder that is already + * present and leaves whatever the link points at open to a vanilla generator. + */ + private static boolean occupiesDimensionPath(Path levelRoot, Path dimensionRoot) { + Path root = levelRoot.toAbsolutePath().normalize(); + Path target = dimensionRoot.toAbsolutePath().normalize(); + if (!target.startsWith(root) || target.equals(root)) { + return Files.isSymbolicLink(target) || Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS); + } + + Path current = root; + for (Path segment : root.relativize(target)) { + current = current.resolve(segment); + if (Files.isSymbolicLink(current)) { + return true; + } + } + return Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS); + } + + private static Path expectedDimensionRoot(Path levelRoot, NamespacedKey worldKey) { + return levelRoot.toAbsolutePath() + .normalize() + .resolve("dimensions") + .resolve(worldKey.getNamespace()) + .resolve(worldKey.getKey()) + .normalize(); + } + + /** + * Every canonical Iris entry in bukkit.yml, in configured-name order, before any storage is inspected. + * Malformed entries throw exactly as they always did; entries that are not Iris', or whose name is not + * the canonical startup name for their key, are dropped. + */ + private static List readIrisWorldCandidates( + YamlConfiguration configuration, + String levelName + ) throws IOException { + Object rawWorlds = configuration.get("worlds"); + ConfigurationSection worlds = configuration.getConfigurationSection("worlds"); + if (rawWorlds != null && worlds == null) { + throw new IOException("bukkit.yml worlds entry is not a section."); + } + if (worlds == null) { + return List.of(); + } + + List configuredNames = new ArrayList<>(worlds.getKeys(false)); + configuredNames.sort(Comparator.naturalOrder()); + List candidates = new ArrayList<>(configuredNames.size()); + for (String configuredName : configuredNames) { + Object rawWorld = worlds.get(configuredName); + ConfigurationSection world = worlds.getConfigurationSection(configuredName); + if (rawWorld != null && world == null) { + throw new IOException("bukkit.yml world entry \"" + configuredName + "\" is not a section."); + } + if (world == null || !world.getKeys(false).contains("generator")) { + continue; + } + Object rawGenerator = world.get("generator"); + if (!(rawGenerator instanceof String generator)) { + throw new IOException("bukkit.yml generator for world \"" + configuredName + + "\" is not a string."); + } + String configuredGenerator = generator.trim(); + if (!configuredGenerator.equalsIgnoreCase("Iris") + && !configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5)) { + continue; + } + + NamespacedKey namespacedKey; + try { + namespacedKey = IrisWorldStorage.keyFromConfiguredWorldName(configuredName, levelName); + } catch (IllegalArgumentException failure) { + throw new IOException("bukkit.yml contains an invalid Iris world name \"" + + configuredName + "\".", failure); + } + if (!"iris".equals(namespacedKey.getNamespace())) { + continue; + } + if (!configuredName.equals(IrisWorldStorage.configuredWorldName(namespacedKey, levelName))) { + continue; + } + candidates.add(new IrisWorldCandidate(configuredName, namespacedKey, configuredGenerator)); + } + return candidates; + } + public static GeneratorReplacement replaceIfMatching( File configurationFile, String worldName, @@ -522,6 +702,51 @@ public final class BukkitWorldConfiguration { UNCHANGED } + public enum IrisWorldStorageState { + /** + * Storage resolves and carries a usable frozen pack snapshot. + */ + PRESENT, + /** + * No world directory at all. The server never enumerates the level, so nothing generates; the + * bukkit.yml and Multiverse entries are left alone so putting the folder back restores the world. + */ + MISSING, + /** + * A world directory the server will load that holds no pack snapshot and no world data - the + * skeleton the server writes back when it saves a level whose folder was deleted underneath it. + * There is nothing to overwrite, so it is reported and the server starts. + */ + EMPTY, + /** + * A world directory the server will load, whose frozen pack snapshot cannot be used. Iris cannot + * generate it and nothing else may. + */ + UNUSABLE + } + + public record IrisWorldStorageEntry( + String configuredWorldName, + WorldSlotKey worldKey, + Path storagePath, + IrisWorldStorageState state, + String detail + ) { + public IrisWorldStorageEntry { + configuredWorldName = requireName(configuredWorldName, "Configured world name"); + Objects.requireNonNull(worldKey, "worldKey"); + Objects.requireNonNull(storagePath, "storagePath"); + Objects.requireNonNull(state, "state"); + } + } + + private record IrisWorldCandidate( + String configuredWorldName, + NamespacedKey worldKey, + String configuredGenerator + ) { + } + public record IrisGeneratorBinding( String configuredWorldName, WorldSlotKey worldKey, diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java b/core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java new file mode 100644 index 000000000..cec987525 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java @@ -0,0 +1,252 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.core.IrisWorldStorage; +import art.arcane.iris.core.WorldSlotKey; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Moves a hot-deleted Iris world's husk out of the dimensions tree before any level is created. + *

+ * Deleting a loaded world's folder and letting the server save the level back leaves a directory holding + * nothing but the server's own {@code data/} skeleton: no frozen pack snapshot, no regions, no mantle. The + * server still enumerates it, and both outcomes stop startup - Iris refuses to boot over a directory it + * cannot generate, and excluding it from the dimension registry instead trips Paper's interactive + * world-migration gate, which consumes no console input and hangs the boot forever. + *

+ * The husk is moved, never deleted: it is recoverable, and the move alone is enough to let the server start. + * The test is deliberately narrow. Anything with chunk data, an {@code iris/} directory holding anything of + * Iris' own, a symbolic link anywhere on the path, or a directory that cannot be read is left exactly where it + * is, for {@link BukkitWorldConfiguration#auditIrisWorldStorage} to fail closed on. + */ +public final class HuskWorldQuarantine { + private static final String IRIS_NAMESPACE = "iris"; + private static final Pattern MANAGED_KEY = Pattern.compile("[a-z0-9_-]+"); + private static final String[] CHUNK_DATA_DIRECTORIES = {"region", "entities", "poi", "mantle"}; + private static final Set OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini"); + private static final String APPLE_DOUBLE_PREFIX = "._"; + private static final DateTimeFormatter STAMP = DateTimeFormatter + .ofPattern("yyyyMMdd'T'HHmmss") + .withZone(ZoneOffset.UTC); + private static final int MAX_DESTINATION_ATTEMPTS = 64; + + private HuskWorldQuarantine() { + } + + public static List quarantineWorthlessHusks(Path levelRoot, Consumer warn) { + return quarantineWorthlessHusks(levelRoot, warn, Instant.now()); + } + + static List quarantineWorthlessHusks(Path levelRoot, Consumer warn, Instant at) { + Path root = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize(); + Consumer log = Objects.requireNonNull(warn, "warn"); + Instant stamp = Objects.requireNonNull(at, "at"); + String levelName = root.getFileName() == null ? null : root.getFileName().toString(); + if (levelName == null || levelName.isBlank()) { + return List.of(); + } + Path namespace = root.resolve("dimensions").resolve(IRIS_NAMESPACE); + if (Files.isSymbolicLink(namespace) || !Files.isDirectory(namespace, LinkOption.NOFOLLOW_LINKS)) { + return List.of(); + } + + List candidates; + try (Stream entries = Files.list(namespace)) { + candidates = entries.sorted().toList(); + } catch (IOException unreadable) { + // An unreadable namespace is not proof that there is nothing in it to lose. + return List.of(); + } + + List quarantined = new ArrayList<>(); + for (Path candidate : candidates) { + String key = candidate.getFileName().toString(); + if (!MANAGED_KEY.matcher(key).matches() || !isWorthlessHusk(candidate)) { + continue; + } + String worldName; + try { + worldName = IrisWorldStorage.configuredWorldName( + new WorldSlotKey(IRIS_NAMESPACE, key), + levelName); + } catch (RuntimeException notAManagedWorld) { + continue; + } + Path destination; + try { + destination = move(root, key, candidate, stamp); + } catch (IOException failure) { + log.accept("Iris husk world " + worldName + " at " + candidate + + " could not be moved aside (" + describe(failure) + + "); startup will stop until it is moved or deleted by hand."); + continue; + } + quarantined.add(new Quarantine(key, candidate, destination)); + log.accept("Iris husk world " + worldName + ": no pack snapshot and no world data; moved " + + candidate + " -> " + destination + + ". Drop the entry with /iris remove world=" + worldName + " delete=true."); + } + return List.copyOf(quarantined); + } + + /** + * True when nothing in this directory can be lost: no frozen pack snapshot, nothing of Iris' own under + * {@code iris/}, and no stored chunk data. Every uncertain answer - a symbolic link, an unreadable + * directory, an entry that is not the type it should be - is false, because uncertainty is not proof of + * worthlessness. + */ + private static boolean isWorthlessHusk(Path dimensionRoot) { + if (!isReadableDirectory(dimensionRoot)) { + return false; + } + if (holdsIrisContent(dimensionRoot.resolve("iris"))) { + return false; + } + for (String directory : CHUNK_DATA_DIRECTORIES) { + if (hasEntries(dimensionRoot.resolve(directory))) { + return false; + } + } + return true; + } + + /** + * True when the {@code iris/} entry holds anything of Iris' own. + *

+ * The desktop file managers recreate a directory to hold their own metadata while a delete is still in + * flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder + * that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are + * ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on + * the list all count as content. + */ + private static boolean holdsIrisContent(Path irisRoot) { + if (Files.isSymbolicLink(irisRoot)) { + return true; + } + if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) { + return true; + } + try (Stream entries = Files.list(irisRoot)) { + return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry)); + } catch (IOException unreadable) { + return true; + } + } + + private static boolean isOperatingSystemMetadata(Path entry) { + if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + Path name = entry.getFileName(); + if (name == null) { + return false; + } + String fileName = name.toString(); + return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX); + } + + /** + * True when the path is a real directory that can actually be listed. The permission bits alone are not + * the answer: what matters is whether Iris can see everything in it before deciding it holds nothing. + */ + private static boolean isReadableDirectory(Path directory) { + if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + try (Stream entries = Files.list(directory)) { + entries.findAny(); + return true; + } catch (IOException unreadable) { + return false; + } + } + + private static boolean hasEntries(Path directory) { + if (Files.isSymbolicLink(directory)) { + return true; + } + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS) + && !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + return true; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + try (Stream entries = Files.list(directory)) { + return entries.findAny().isPresent(); + } catch (IOException unreadable) { + return true; + } + } + + private static Path move(Path levelRoot, String key, Path husk, Instant at) throws IOException { + Path huskRoot = requireDirectoryPath(levelRoot, levelRoot.resolve(IRIS_NAMESPACE).resolve("husks")); + String base = key + "-" + STAMP.format(at); + for (int attempt = 1; attempt <= MAX_DESTINATION_ATTEMPTS; attempt++) { + Path destination = huskRoot.resolve(attempt == 1 ? base : base + "-" + attempt); + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + try { + Files.move(husk, destination, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException notAtomic) { + Files.move(husk, destination); + } + DirectoryDurability.forceDirectoryAfterCommit(huskRoot, "An Iris husk world quarantine"); + return destination; + } + throw new IOException("No free husk destination under " + huskRoot + " for " + key); + } + + /** + * Creates the quarantine directory, refusing when any segment below the level root is a symbolic link so + * a hostile or accidental link cannot redirect a world folder out of the level root. Segments above the + * level root are the server's own installation path and are not Iris' to judge. + */ + private static Path requireDirectoryPath(Path levelRoot, Path directory) throws IOException { + Path current = levelRoot; + for (Path segment : levelRoot.relativize(directory)) { + current = current.resolve(segment); + if (Files.isSymbolicLink(current)) { + throw new IOException("Husk quarantine path contains a symbolic link: " + current); + } + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS) + && !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Husk quarantine path is not a directory: " + current); + } + } + Files.createDirectories(directory); + return directory; + } + + private static String describe(Throwable failure) { + String message = failure.getMessage(); + return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; + } + + public record Quarantine(String worldKey, Path husk, Path destination) { + public Quarantine { + Objects.requireNonNull(worldKey, "worldKey"); + Objects.requireNonNull(husk, "husk"); + Objects.requireNonNull(destination, "destination"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/ManagedWorldLoader.java b/core/src/main/java/art/arcane/iris/core/lifecycle/ManagedWorldLoader.java new file mode 100644 index 000000000..c559ef23e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/ManagedWorldLoader.java @@ -0,0 +1,38 @@ +package art.arcane.iris.core.lifecycle; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +/** + * Loads a persistent Iris world back into a running server. + *

+ * Iris creates its worlds from a keyed WorldCreator so the level lands in + * {@code /dimensions/iris/} and carries the environment the pack's dimension declares. + * Nothing else can: a plain name-keyed creator builds a fresh vanilla world beside the level root, and a + * custom-dimension world reports {@code CUSTOM} after its first restart, which CraftServer refuses outright. + * Any integration that wants an Iris world loaded has to come through here. + *

+ * The implementation is installed by the platform adapter; core resolves it through + * {@link art.arcane.iris.spi.IrisServices} and degrades when nothing is registered. + */ +public interface ManagedWorldLoader { + /** + * Loads the world registered under {@code configuredWorldName} - the Bukkit startup name, not an alias. + * Never throws: a refusal is reported through the returned outcome. + */ + CompletableFuture load(String configuredWorldName); + + record ManagedWorldLoad(boolean loaded, String message) { + public ManagedWorldLoad { + Objects.requireNonNull(message, "message"); + } + + public static ManagedWorldLoad loaded(String message) { + return new ManagedWorldLoad(true, message); + } + + public static ManagedWorldLoad failed(String message) { + return new ManagedWorldLoad(false, message); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLog.java b/core/src/main/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLog.java new file mode 100644 index 000000000..ffe5a4688 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLog.java @@ -0,0 +1,118 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * One warning per orphaned Iris world per boot. + *

+ * The bootstrap storage audit, the bukkit.yml binding read and the worlds.json registry read all notice the + * same orphan, and the registry read runs on every {@code getWorlds()}, so the warning is deduplicated by + * configured world name instead of being emitted per code path. + *

+ * Paper runs the plugin bootstrap before any plugin logger exists, so a warning raised there reaches the + * console and the runtime log but never the instance's {@code logs/latest.log}, which is the only log most + * operators read. Warnings raised while no platform is bound are held and re-emitted once by + * {@link #replayToPlatformLog()}. + *

+ * Nothing here prunes configuration. A world folder can be missing because a drive is unmounted or a backup + * restore is in flight, and the surviving bukkit.yml and Multiverse entries are exactly what makes putting + * the folder back a complete recovery. + */ +public final class MissingWorldStorageLog { + private static final Set WARNED = ConcurrentHashMap.newKeySet(); + private static final List PENDING = new ArrayList<>(); + private static final Object PENDING_LOCK = new Object(); + + private MissingWorldStorageLog() { + } + + public static void warnOnce(String configuredWorldName, Path expectedStorage) { + String worldName = Objects.requireNonNull(configuredWorldName, "configuredWorldName"); + Path storage = Objects.requireNonNull(expectedStorage, "expectedStorage"); + if (!WARNED.add(worldName)) { + return; + } + emit(new String[]{ + IrisLogging.format("Iris world %s is configured but has no world storage at %s; it will not load.", + worldName, storage), + IrisLogging.format("Restore that folder, or drop the world with /iris remove world=%s delete=true.", + worldName) + }); + } + + /** + * Reports a world folder that survives with no pack snapshot and no world data - the skeleton the server + * writes back when it saves a level whose folder was deleted underneath it. There is nothing in it to + * lose, so it is reported instead of stopping startup, and the remedy is a delete rather than a restore. + */ + public static void warnEmptyOnce(String configuredWorldName, Path storage) { + String worldName = Objects.requireNonNull(configuredWorldName, "configuredWorldName"); + Path worldStorage = Objects.requireNonNull(storage, "storage"); + if (!WARNED.add(worldName)) { + return; + } + emit(new String[]{ + IrisLogging.format("Iris world %s has an empty world folder at %s: no pack snapshot, no region" + + " data; it will generate as vanilla terrain.", worldName, worldStorage), + IrisLogging.format("Delete that folder, or drop the world with /iris remove world=%s delete=true.", + worldName) + }); + } + + public static boolean hasWarned(String configuredWorldName) { + return WARNED.contains(Objects.requireNonNull(configuredWorldName, "configuredWorldName")); + } + + /** + * Re-emits every warning raised before a platform was bound, exactly once. Safe to call more than once + * and safe to call when nothing was held. + */ + public static void replayToPlatformLog() { + List held; + synchronized (PENDING_LOCK) { + if (PENDING.isEmpty()) { + return; + } + held = List.copyOf(PENDING); + PENDING.clear(); + } + for (String[] lines : held) { + write(lines); + } + } + + /** + * Visible for tests: the deduplication set is per JVM, not per server boot. + */ + public static void reset() { + WARNED.clear(); + synchronized (PENDING_LOCK) { + PENDING.clear(); + } + } + + private static void emit(String[] lines) { + write(lines); + if (IrisPlatforms.isBound()) { + return; + } + synchronized (PENDING_LOCK) { + PENDING.add(lines); + } + } + + private static void write(String[] lines) { + for (String line : lines) { + // Pre-formatted: passing no arguments keeps a path containing '%' out of the format engine. + IrisLogging.warn(line); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/VanishedWorldStorage.java b/core/src/main/java/art/arcane/iris/core/lifecycle/VanishedWorldStorage.java new file mode 100644 index 000000000..2f048e9fe --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/VanishedWorldStorage.java @@ -0,0 +1,81 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.spi.IrisLogging; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Detects an Iris world folder that was deleted out from under a live engine. + *

+ * Every persistence path resolves its target from a world folder captured when the engine bound, and the + * writes create their own parents. After an external delete that turns a save into a rebuild: a directory + * tree with engine data and server {@code .dat} files but no pack snapshot and no regions, which the next + * boot reads as an owned-but-broken world. Persistence stops instead, once the folder is gone. + *

+ * The verdict latches. A {@code save-all} after the delete writes the level's own {@code data/*.dat} files + * back, which recreates the world folder, and a plain "is it a directory" check would then let Iris resume + * writing into a tree that no longer holds the world. It also accepts a directory Iris established under the + * folder: once Iris has written {@code iris/engine-data} there, that directory disappearing is a delete + * whether or not the server has already put the folder back, which is what makes the next boot's storage + * classification the same on every run instead of depending on save ordering. + */ +public final class VanishedWorldStorage { + private static final Set VANISHED = ConcurrentHashMap.newKeySet(); + + private VanishedWorldStorage() { + } + + /** + * True when the world folder is no longer a directory, or was already found gone earlier in this JVM. + */ + public static boolean vanished(File worldFolder) { + return vanished(worldFolder, null); + } + + /** + * True when the world folder is gone, or when {@code establishedTree} - a directory Iris is known to have + * created under it already - is gone. Reports the first time it sees each folder. + * + * @param establishedTree a directory Iris has already written, or null when it has written none yet + */ + public static boolean vanished(File worldFolder, File establishedTree) { + if (worldFolder == null) { + return false; + } + Path path = worldFolder.toPath().toAbsolutePath().normalize(); + if (VANISHED.contains(path.toString())) { + return true; + } + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + return report(path); + } + if (establishedTree == null) { + return false; + } + Path tree = establishedTree.toPath().toAbsolutePath().normalize(); + if (Files.isDirectory(tree, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + return report(path); + } + + /** + * Visible for tests: the report set is per JVM, not per server boot. + */ + public static void reset() { + VANISHED.clear(); + } + + private static boolean report(Path path) { + if (VANISHED.add(path.toString())) { + IrisLogging.error("Iris world storage is gone at %s; that world is no longer being written.", path); + IrisLogging.error("Unload or remove the world, or restore the folder and restart the server."); + } + return true; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java b/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java index e2ed0b771..dab62c6d6 100644 --- a/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java +++ b/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java @@ -19,23 +19,33 @@ package art.arcane.iris.core.link; import art.arcane.iris.core.IrisWorldStorage; +import art.arcane.iris.core.IrisWorlds; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.platform.bukkit.BukkitEnvironment; import art.arcane.iris.spi.IrisLogging; -import lombok.SneakyThrows; +import art.arcane.volmlib.util.bukkit.WorldIdentity; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.mvplugins.multiverse.core.MultiverseCoreApi; +import org.mvplugins.multiverse.core.locale.message.Message; import org.mvplugins.multiverse.core.utils.result.Attempt; +import org.mvplugins.multiverse.core.utils.result.FailureReason; +import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld; import org.mvplugins.multiverse.core.world.MultiverseWorld; import org.mvplugins.multiverse.core.world.WorldManager; import org.mvplugins.multiverse.core.world.options.ImportWorldOptions; +import org.mvplugins.multiverse.core.world.options.LoadWorldOptions; import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions; +import org.mvplugins.multiverse.core.world.reasons.LoadFailureReason; import org.mvplugins.multiverse.core.world.reasons.RemoveFailureReason; +import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -44,37 +54,63 @@ import java.util.Objects; * its worlds under the Paper startup name ({@code __}), so the name Multiverse * sees at import is already the name it will see again after every restart, and it is the name every * Iris unregistration uses. + *

+ * Every entry point here degrades instead of throwing when Multiverse is absent, not yet loaded, or + * has moved the internals this link reflects into. A server without Multiverse must behave exactly as + * it did before this link existed. */ public class MultiverseCoreLink { + public static final String MULTIVERSE_PLUGIN = "Multiverse-Core"; + private static final String GENERATOR_PREFIX = "Iris:"; + /** + * Reflection into Multiverse internals is warned about once. A server that keeps logging the same + * broken accessor for every world tells the admin nothing the first line did not. + */ + private static volatile boolean worldConfigUnwritable; + /** * Best-effort scrub for worlds Iris never registered (studio worlds). Multiverse not knowing the * world is the normal case here, so a miss is not worth a warning. */ public boolean removeIfPresent(World world) { String worldName = Objects.requireNonNull(world, "world").getName(); - if (!isActive()) { - return false; - } WorldManager manager = worldManager(); - MultiverseWorld multiverseWorld = resolve(manager, worldName); - if (multiverseWorld == null) { + if (manager == null) { + return false; + } + try { + MultiverseWorld multiverseWorld = resolve(manager, worldName); + return multiverseWorld != null && remove(manager, multiverseWorld, worldName); + } catch (Throwable failure) { + // Studio teardown is not allowed to fail over a Multiverse bookkeeping entry. + IrisLogging.warn("Multiverse refused to drop \"%s\": %s", worldName, describe(failure)); return false; } - return remove(manager, multiverseWorld, worldName); } /** * Authoritative unregistration for a world Iris registered. A miss leaves a ghost entry behind in - * Multiverse's worlds.yml, so it is reported. + * Multiverse's worlds.yml, so it is reported. A refusal from Multiverse itself is raised, because + * the caller is a removal the admin asked for and a silent success would delete the world files + * while Multiverse kept pointing at them. */ public boolean removeFromConfig(String configuredWorldName) { - String worldName = requireWorldName(configuredWorldName); - if (!isActive()) { - IrisLogging.debug("Multiverse is not enabled; skipped unregistering \"" + worldName + "\"."); + WorldManager manager = worldManager(); + if (manager == null) { + IrisLogging.debug("Multiverse is not available; skipped unregistering \"" + + configuredWorldName + "\"."); + return false; + } + String worldName = requireWorldName(configuredWorldName); + MultiverseWorld multiverseWorld; + try { + multiverseWorld = resolve(manager, worldName); + } catch (Throwable failure) { + // Only a refusal Multiverse states is worth stopping a removal for; an unusable link is not. + IrisLogging.warn("Could not look %s up in Multiverse: %s; its worlds.yml entry was left as-is.", + worldName, describe(failure)); return false; } - WorldManager manager = worldManager(); - MultiverseWorld multiverseWorld = resolve(manager, worldName); if (multiverseWorld == null) { IrisLogging.warn("Multiverse has no world registered as %s; its worlds.yml entry was left as-is.", worldName); @@ -83,55 +119,394 @@ public class MultiverseCoreLink { return remove(manager, multiverseWorld, worldName); } - @SneakyThrows + /** + * Registers a freshly created world with Multiverse. Never throws: a Multiverse failure here used + * to unwind {@code IrisCreator}, whose rollback deletes the world storage that was just built. + */ public void updateWorld(World bukkitWorld, String configuredWorldName, String pack) { - World world = Objects.requireNonNull(bukkitWorld, "bukkitWorld"); - String worldName = requireWorldName(configuredWorldName); - if (!isActive()) { + try { + World world = Objects.requireNonNull(bukkitWorld, "bukkitWorld"); + String worldName = requireWorldName(configuredWorldName); + WorldManager manager = worldManager(); + if (manager == null) { + return; + } + if (!worldName.equals(world.getName())) { + // Multiverse records the live name as legacy-world-name. A live name that is not the + // startup name makes it re-import the world next boot and collide with its own config key. + IrisLogging.info("World %s is live as %s; Multiverse will record the live name.", + worldName, world.getName()); + } + String generator = GENERATOR_PREFIX + Objects.requireNonNull(pack, "pack"); + MultiverseWorld multiverseWorld = manager.getWorld(world) + .orElse(() -> manager.getWorld(worldName)) + .getOrElse(() -> { + // Import through the live world so Multiverse binds its own config key to the + // key Paper gave the world and records the startup name it was created under. + ImportWorldOptions options = ImportWorldOptions.worldName(world.getName()) + .generator(generator) + .environment(world.getEnvironment()) + .useSpawnAdjust(false); + return manager.importWorld(options).get(); + }); + + applyIrisWorldSettings(multiverseWorld, generator); + manager.saveWorldsConfig().get(); + } catch (Throwable failure) { + IrisLogging.warn("Multiverse registration failed for %s: %s; the world is unaffected.", + String.valueOf(configuredWorldName), describe(failure)); + } + } + + /** + * Re-imposes Iris' intended Multiverse state on every world Iris owns. + *

+ * Iris registers worlds it creates, but worlds reconciled out of bukkit.yml at boot are registered + * by Multiverse's own auto-import instead, which defaults {@code auto-load} and {@code adjust-spawn} + * on. Auto-load makes Multiverse try to load an Iris world every boot and log a SEVERE when Paper + * has already loaded it, and adjust-spawn hands Multiverse the authority to relocate and persist a + * spawn Iris chose. + *

+ * The stored environment is corrected here too. Multiverse refuses to adopt a world whose live + * environment differs from the one it stored, and an Iris world's live environment is not stable across a + * restart: it is whatever the pack declares in the session the world was created or loaded in, and + * {@code CUSTOM} once the server enumerates the level from {@code /dimensions/iris/} on + * the next boot. A runtime {@code /mv load} therefore leaves Multiverse holding the pack's environment, + * and this pass is what moves it back to the live one before adoption is attempted. + *

+ * This is also where a world Iris already loaded is offered to Multiverse, which is what makes the + * running world visible to every Multiverse command that needs a loaded one. + * + * @return the number of worlds whose Multiverse entry was corrected; adoption is reported separately + * because it changes Multiverse's registry rather than the world's entry + */ + public int reconcileOwnedWorlds() { + WorldManager manager = worldManager(); + if (manager == null) { + return 0; + } + Map owned; + String levelName; + try { + levelName = IrisWorldStorage.levelRoot().getName(); + owned = IrisWorlds.get().getWorlds(); + } catch (Throwable failure) { + IrisLogging.warn("Could not read the Iris world registry for Multiverse reconciliation: %s", + describe(failure)); + return 0; + } + + int corrected = 0; + int adopted = 0; + for (Map.Entry entry : owned.entrySet()) { + NamespacedKey worldKey; + String worldName; + try { + worldKey = WorldIdentity.parse(entry.getKey()); + worldName = IrisWorldStorage.configuredWorldName(worldKey, levelName); + } catch (Throwable ignored) { + // Not a world with a Bukkit startup name; Multiverse cannot be holding an entry for it. + continue; + } + try { + MultiverseWorld multiverseWorld = resolve(manager, worldName, levelName); + if (multiverseWorld == null) { + continue; + } + warnOnStaleRecordedName(multiverseWorld, worldName); + boolean changed = applyIrisWorldEnvironment( + multiverseWorld, + ownedWorldEnvironment(worldKey, entry.getValue())); + changed = applyIrisWorldSettings(multiverseWorld, GENERATOR_PREFIX + entry.getValue()) || changed; + if (changed) { + corrected++; + } + if (adoptLoadedWorld(manager, multiverseWorld, worldKey)) { + adopted++; + } + } catch (Throwable failure) { + IrisLogging.warn("Could not correct the Multiverse entry for %s: %s", + worldName, describe(failure)); + } + } + if (corrected > 0) { + try { + manager.saveWorldsConfig().get(); + } catch (Throwable failure) { + IrisLogging.warn("Could not save worlds.yml after Multiverse reconciliation: %s", + describe(failure)); + } + IrisLogging.info("Corrected %d Multiverse world entr%s.", corrected, corrected == 1 ? "y" : "ies"); + } + if (adopted > 0) { + IrisLogging.notice("Adopted %d live Iris world%s into Multiverse.", adopted, adopted == 1 ? "" : "s"); + } + return corrected; + } + + /** + * Points Multiverse's stored environment at the one Iris is about to create the world with, so the + * adoption Multiverse runs from its own {@code WorldLoadEvent} handler does not fail on the mismatch. + *

+ * Multiverse compares the stored environment to the live world's before it will take a world, and it + * rewrites the stored value from the live world every time it binds one. An Iris world reports the + * environment its pack declares in the session it was created and {@code CUSTOM} on every later boot, so + * the stored value is only ever right for one of the two - and it is this call, plus the boot-time pass + * in {@link #reconcileOwnedWorlds()}, that moves it between them. + */ + public void prepareOwnedWorldLoad(String configuredWorldName) { + try { + WorldManager manager = worldManager(); + if (manager == null) { + return; + } + String worldName = requireWorldName(configuredWorldName); + String levelName = IrisWorldStorage.levelRoot().getName(); + NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(worldName, levelName); + MultiverseWorld multiverseWorld = resolve(manager, worldName, levelName); + if (multiverseWorld == null) { + return; + } + if (applyIrisWorldEnvironment( + multiverseWorld, + ownedWorldEnvironment(worldKey, ownedWorldPack(worldKey)))) { + manager.saveWorldsConfig().get(); + } + } catch (Throwable failure) { + IrisLogging.warn("Could not prepare the Multiverse entry for %s: %s", + String.valueOf(configuredWorldName), describe(failure)); + } + } + + /** + * Offers a world Iris has just loaded to Multiverse, importing it when Multiverse has no entry at all. + * + * @return true when Multiverse now tracks the world as loaded + */ + public boolean adoptOwnedWorld(String configuredWorldName) { + try { + WorldManager manager = worldManager(); + if (manager == null) { + return false; + } + String worldName = requireWorldName(configuredWorldName); + String levelName = IrisWorldStorage.levelRoot().getName(); + NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(worldName, levelName); + World live = WorldIdentity.resolve(worldKey).orElse(null); + if (live == null) { + return false; + } + String pack = ownedWorldPack(worldKey); + MultiverseWorld multiverseWorld = resolve(manager, worldName, levelName); + if (multiverseWorld == null) { + if (pack == null) { + return false; + } + updateWorld(live, worldName, pack); + return true; + } + boolean changed = applyIrisWorldEnvironment(multiverseWorld, live.getEnvironment()); + changed = applyIrisWorldSettings(multiverseWorld, pack == null ? null : GENERATOR_PREFIX + pack) + || changed; + boolean adopted = adoptLoadedWorld(manager, multiverseWorld, worldKey); + if (changed || adopted) { + manager.saveWorldsConfig().get(); + } + return adopted; + } catch (Throwable failure) { + IrisLogging.warn("Could not hand %s to Multiverse: %s", + String.valueOf(configuredWorldName), describe(failure)); + return false; + } + } + + private static String ownedWorldPack(NamespacedKey worldKey) { + try { + return IrisWorlds.get().getWorlds().get(worldKey.toString()); + } catch (Throwable unreadable) { + IrisLogging.debug("Could not read the Iris world registry for " + worldKey + ": " + + describe(unreadable)); + return null; + } + } + + /** + * Binds a live Iris world into Multiverse's loaded registry. + *

+ * Iris turns {@code auto-load} off and loads its worlds out of bukkit.yml before Multiverse enables, so + * Multiverse never adopts them: {@code mv list} calls a running world UNLOADED and every command that + * needs a loaded world - {@code mv info}, {@code mv gamerule} - refuses it. + *

+ * Multiverse's {@code loadWorld} short-circuits to {@code Bukkit.getWorld()} when that + * world already exists and binds it without going near a WorldCreator. That short circuit is the whole + * safety property here: without a live world under the name Multiverse recorded, the same call would + * build one over Iris storage instead, so the world is looked up and identified first and adoption is + * skipped whenever it is not the target. + * + * @return true when Multiverse took the world + */ + boolean adoptLoadedWorld(WorldManager manager, MultiverseWorld multiverseWorld, NamespacedKey worldKey) { + if (manager.isLoadedWorld(multiverseWorld)) { + return false; + } + World live = Bukkit.getWorld(multiverseWorld.getName()); + if (live == null || !worldKey.equals(WorldIdentity.key(live))) { + return false; + } + Attempt adoption = + manager.loadWorld(LoadWorldOptions.world(multiverseWorld)); + if (adoption.isFailure()) { + IrisLogging.warn("Multiverse would not adopt live world %s: %s", + multiverseWorld.getName(), describeFailure(adoption)); + return false; + } + return true; + } + + /** + * The Bukkit environment an Iris-owned world is or will be live with, or null when it cannot be + * determined. + *

+ * A live world already carries it, and it is the value Multiverse compares its own against before it will + * adopt the world, so it wins whenever there is one. With no live world the pack's dimension answers it - + * the same {@code BukkitEnvironment.from(dimension.getEnvironment())} Iris' own load path passes to the + * WorldCreator - so a world about to be loaded gets the environment it is about to have, not a guess at + * what Multiverse wants to hear. + */ + World.Environment ownedWorldEnvironment(NamespacedKey worldKey, String pack) { + World live = WorldIdentity.resolve(worldKey).orElse(null); + if (live != null) { + return live.getEnvironment(); + } + try { + IrisDimension dimension = IrisWorlds.get().getDimension(worldKey.toString(), pack); + return dimension == null ? null : BukkitEnvironment.from(dimension.getEnvironment()); + } catch (Throwable unreadable) { + IrisLogging.debug("Could not read the Iris dimension for " + worldKey + ": " + describe(unreadable)); + return null; + } + } + + /** + * Corrects the environment Multiverse stored for an Iris world. + *

+ * Multiverse compares the stored value to the live world's and refuses to adopt the world when they + * differ, and it rewrites the stored value from the live world every time it binds one. An Iris world's + * live environment flips between the pack's own and {@code CUSTOM} across a restart, so this is the only + * thing that keeps the two in step; without it {@code mv list} reports a running world as unloaded and + * every command that needs a loaded one refuses it. + * + * @return true when something was changed + */ + boolean applyIrisWorldEnvironment(MultiverseWorld multiverseWorld, World.Environment environment) { + if (environment == null || environment == multiverseWorld.getEnvironment()) { + return false; + } + return setWorldConfigValue(multiverseWorld, "setEnvironment", World.Environment.class, environment); + } + + /** + * Re-imposes Iris ownership on one Multiverse entry. Iris drives loading through bukkit.yml and owns + * the spawn, so Multiverse must neither load the world nor move its spawn. + * + * @return true when something was changed + */ + boolean applyIrisWorldSettings(MultiverseWorld multiverseWorld, String generator) { + boolean changed = false; + if (multiverseWorld.isAutoLoad()) { + multiverseWorld.setAutoLoad(false); + changed = true; + } + if (multiverseWorld.getAdjustSpawn()) { + multiverseWorld.setAdjustSpawn(false); + changed = true; + } + if (generator != null && !generator.equals(multiverseWorld.getGenerator())) { + changed = setWorldConfigValue(multiverseWorld, "setGenerator", String.class, generator) || changed; + } + return changed; + } + + /** + * Multiverse tracks the world name it saw at import and indexes its store by it. Iris cannot rewrite + * that name without desynchronising Multiverse's own name index, so a mismatch is reported instead. + */ + void warnOnStaleRecordedName(MultiverseWorld multiverseWorld, String configuredWorldName) { + String recorded = multiverseWorld.getName(); + if (configuredWorldName.equals(recorded)) { return; } - if (!worldName.equals(world.getName())) { - // Multiverse records the live name as legacy-world-name. A live name that is not the - // startup name makes it re-import the world next boot and collide with its own config key. - IrisLogging.warn("World %s is live as %s; Multiverse will record the live name.", - worldName, world.getName()); - } - String generator = "Iris:" + pack; - WorldManager manager = worldManager(); - MultiverseWorld multiverseWorld = manager.getWorld(world) - .orElse(() -> manager.getWorld(worldName)) - .getOrElse(() -> { - // Import through the live world so Multiverse binds its own config key to the key - // Paper gave the world and records the startup name the world was created under. - ImportWorldOptions options = ImportWorldOptions.worldName(world.getName()) - .generator(generator) - .environment(world.getEnvironment()) - .useSpawnAdjust(false); - return manager.importWorld(options).get(); - }); + IrisLogging.warn("Multiverse records %s as %s; remove that worlds.yml entry so Iris can re-register it.", + configuredWorldName, recorded); + } - multiverseWorld.setAutoLoad(false); - if (!generator.equals(multiverseWorld.getGenerator())) { - setWorldConfigString(multiverseWorld, "setGenerator", generator); + /** + * Reports whether a Bukkit world name names a persistent world Iris owns on disk. Never throws, so a + * name Iris has no opinion about simply answers false. + */ + public static boolean isIrisOwnedWorldName(String worldName, File levelRoot) { + if (worldName == null || levelRoot == null) { + return false; } - - manager.saveWorldsConfig().get(); + String levelName = levelRoot.getName(); + NamespacedKey key; + try { + key = IrisWorldStorage.managedKeyFromName(worldName.trim(), levelName); + } catch (RuntimeException notAnIrisName) { + return false; + } + return IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, key); } private boolean remove(WorldManager manager, MultiverseWorld multiverseWorld, String worldName) { - Attempt removal = manager.removeWorld(RemoveWorldOptions.world(multiverseWorld)); + Attempt removal = manager.removeWorld(removalOptions(multiverseWorld)); if (removal.isFailure()) { throw new IllegalStateException("Multiverse refused to remove world \"" + worldName + "\": " - + removal.getFailureMessage()); + + describeFailure(removal)); } manager.saveWorldsConfig().get(); return true; } - private MultiverseWorld resolve(WorldManager manager, String worldName) { - for (String candidate : lookupNames(worldName, IrisWorldStorage.levelRoot().getName())) { + /** + * Removal options for one Multiverse world. + *

+ * Iris evacuates, unloads and closes a world before it unregisters it, but Multiverse still holds it in + * its loaded registry and its unload-before-remove dereferences the Bukkit world that is already gone, + * failing the whole removal. Multiverse only owns the unload while the Bukkit world is genuinely live. + */ + static RemoveWorldOptions removalOptions(MultiverseWorld multiverseWorld) { + RemoveWorldOptions options = RemoveWorldOptions.world(multiverseWorld); + if (hasLiveBukkitWorld(multiverseWorld)) { + return options; + } + return options.unloadBukkitWorld(false).saveBukkitWorld(false); + } + + private static boolean hasLiveBukkitWorld(MultiverseWorld multiverseWorld) { + LoadedMultiverseWorld loaded = multiverseWorld.asLoadedWorld().getOrNull(); + return loaded != null && loaded.getBukkitWorld().getOrNull() != null; + } + + MultiverseWorld resolve(WorldManager manager, String worldName) { + return resolve(manager, worldName, IrisWorldStorage.levelRoot().getName()); + } + + /** + * The Multiverse entry for one Iris world, under whichever of its names Multiverse currently holds. + *

+ * A name other than the one asked for only identifies the same world when Multiverse keys its entry by + * the Iris world key, so an entry sitting on the runtime name under some other key is never returned: + * a removal must not delete a world that is not its target. + */ + MultiverseWorld resolve(WorldManager manager, String worldName, String levelName) { + NamespacedKey expectedKey = managedKeyOrNull(worldName, levelName); + for (String candidate : lookupNames(worldName, levelName)) { MultiverseWorld multiverseWorld = manager.getWorld(candidate).getOrElse((MultiverseWorld) null); - if (multiverseWorld != null) { + if (multiverseWorld == null) { + continue; + } + if (candidate.equals(worldName) || expectedKey != null && expectedKey.equals(multiverseWorld.getKey())) { return multiverseWorld; } } @@ -139,32 +514,83 @@ public class MultiverseCoreLink { } /** - * worlds.yml files written by Iris builds that created keyed worlds under {@code _} - * still carry that name, so a startup name must also resolve through the Multiverse config key it - * encodes or those entries can never be removed. + * Every name one Iris world can be registered under in Multiverse: the Bukkit startup name Iris + * registers, the Multiverse config key, and the {@code _} runtime name a world gets + * when Multiverse re-creates it from a keyed WorldCreator. Without all three an entry Multiverse + * reloaded under a different form can never be removed. */ static List lookupNames(String worldName, String levelName) { - List candidates = new ArrayList<>(2); + List candidates = new ArrayList<>(3); candidates.add(worldName); - try { - NamespacedKey key = IrisWorldStorage.managedKeyFromName(worldName, levelName); - if (IrisWorldStorage.configuredWorldName(key, levelName).equals(worldName)) { - candidates.add(key.toString()); - } - } catch (IllegalArgumentException ignored) { - // Not an Iris startup name; the plain name is the only identity Multiverse can have. + NamespacedKey key = managedKeyOrNull(worldName, levelName); + if (key != null && IrisWorldStorage.configuredWorldName(key, levelName).equals(worldName)) { + candidates.add(key.toString()); + candidates.add(key.getNamespace() + "_" + key.getKey()); } return List.copyOf(candidates); } - private static void setWorldConfigString(MultiverseWorld world, String setter, String value) throws Exception { - Field field = MultiverseWorld.class.getDeclaredField("worldConfig"); - field.setAccessible(true); + private static NamespacedKey managedKeyOrNull(String worldName, String levelName) { + try { + return IrisWorldStorage.managedKeyFromName(worldName, levelName); + } catch (IllegalArgumentException notAnIrisName) { + // Not an Iris startup name; the plain name is the only identity Multiverse can have. + return null; + } + } - Object config = field.get(world); - Method method = config.getClass().getDeclaredMethod(setter, String.class); - method.setAccessible(true); - method.invoke(config, value); + /** + * {@code WorldConfig} and its setters are package-private in Multiverse, so its stored values can only + * be corrected reflectively. A Multiverse release that renames either must degrade to leaving the value + * alone rather than taking down whatever asked for the write. + */ + private static boolean setWorldConfigValue( + MultiverseWorld world, + String setter, + Class parameterType, + Object value + ) { + if (worldConfigUnwritable) { + return false; + } + try { + Field field = MultiverseWorld.class.getDeclaredField("worldConfig"); + field.setAccessible(true); + + Object config = field.get(world); + Method method = config.getClass().getDeclaredMethod(setter, parameterType); + method.setAccessible(true); + method.invoke(config, value); + return true; + } catch (ReflectiveOperationException | RuntimeException | Error failure) { + worldConfigUnwritable = true; + // A probe on a private Multiverse field. It is latched, the fallback is Multiverse's own + // handling of the setting, and nothing an operator can do changes the answer. + IrisLogging.info("Multiverse WorldConfig.%s is not reachable; Iris world settings are left to Multiverse.", + setter); + return false; + } + } + + /** + * A Multiverse failure reason as text. {@code getFailureMessage} is a Multiverse {@code Message}, whose + * rendering goes through a locale manager that is not installed in every teardown order, so the reason + * name is the fallback rather than an object identity string. + */ + private static String describeFailure(Attempt attempt) { + Message message = attempt.getFailureMessage(); + if (message != null) { + try { + String formatted = message.formatted(); + if (formatted != null && !formatted.isBlank()) { + return formatted; + } + } catch (RuntimeException | Error unrenderable) { + // Fall through to the reason name. + } + } + FailureReason reason = attempt.getFailureReason(); + return reason == null ? "no reason given" : reason.toString(); } private static String requireWorldName(String worldName) { @@ -175,12 +601,38 @@ public class MultiverseCoreLink { return name; } - private WorldManager worldManager() { - MultiverseCoreApi api = MultiverseCoreApi.get(); - return api.getWorldManager(); + private static String describe(Throwable failure) { + String message = failure.getMessage(); + return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; } - private boolean isActive() { - return Bukkit.getPluginManager().isPluginEnabled("Multiverse-Core"); + /** + * The world manager, or null when Multiverse cannot serve one. Plugin enablement alone is not enough: + * the API singleton is installed during Multiverse's own enable, and Iris is asked for generators + * before that finishes. + */ + WorldManager worldManager() { + if (!isActive()) { + return null; + } + try { + MultiverseCoreApi api = MultiverseCoreApi.get(); + return api.getWorldManager(); + } catch (RuntimeException | Error failure) { + IrisLogging.debug("Multiverse world manager is unavailable: " + describe(failure)); + return null; + } + } + + public boolean isActive() { + if (!Bukkit.getPluginManager().isPluginEnabled(MULTIVERSE_PLUGIN)) { + return false; + } + try { + return MultiverseCoreApi.isLoaded(); + } catch (Error missingApi) { + // Multiverse older than 5.1 has no readiness probe; enablement is the only signal there is. + return true; + } } } diff --git a/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java b/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java new file mode 100644 index 000000000..a77a1851f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java @@ -0,0 +1,487 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.link; + +import art.arcane.iris.core.IrisWorldStorage; +import art.arcane.iris.core.lifecycle.ManagedWorldLoader; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.server.ServerCommandEvent; +import org.mvplugins.multiverse.core.event.world.MVWorldDeleteEvent; +import org.mvplugins.multiverse.core.event.world.MVWorldImportedEvent; +import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld; +import org.mvplugins.multiverse.core.world.MultiverseWorld; +import org.mvplugins.multiverse.core.world.WorldManager; + +import java.lang.ref.WeakReference; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.CompletionException; + +/** + * Keeps Multiverse's destructive world commands away from Iris storage. + *

+ * An Iris world folder is not a vanilla world folder: {@code /dimensions/iris/} also holds + * {@code iris/pack}, the frozen pack snapshot the world is generated from, and {@code iris/engine-data}. + * Multiverse deletes a world by recursively deleting its Bukkit world folder, and its {@code --keep-files} + * only matches file basenames, so a pack directory can never survive. The delete leaves the world, the + * pack and the Multiverse entry gone while bukkit.yml and Iris' worlds.json still point at them, and a + * half-deleted tree reads back as an owned-but-broken world. + *

+ * This listener is only registered while Multiverse is enabled. Every handler here names a Multiverse + * type, so registering it without Multiverse on the classpath would fail at handler resolution. + */ +public final class MultiverseGuardListener implements Listener { + private static final String IRIS_REMOVE_HINT = "Use /iris remove world=%s delete=true instead."; + private static final String MULTIVERSE_LOAD_PERMISSION = "multiverse.core.load"; + private final MultiverseCoreLink link; + /** + * Multiverse world events carry no command sender, and its own cancellation message is the generic + * failure string, so the sender who typed the command has to be remembered across the dispatch. The + * capture is consumed on first read so a later API-driven delete cannot message an unrelated player. + */ + private volatile WeakReference multiverseCommandSender; + + public MultiverseGuardListener(MultiverseCoreLink link) { + this.link = Objects.requireNonNull(link, "link"); + } + + /** + * Cancels {@code mv delete} and {@code mv regen} for Iris worlds. + *

+ * Both commands funnel through {@code WorldManager#doDeleteWorld}, which fires this event after + * resolving the world folder but before it unloads the world or touches disk, so a cancel here is + * free of side effects. Regen is refused with delete because the event cannot tell them apart, and + * because a regen Iris did not drive would have Multiverse re-create the world through its own + * {@code CreateWorldOptions} rather than through Iris storage. + */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onMultiverseWorldDelete(MVWorldDeleteEvent event) { + MultiverseWorld multiverseWorld = event.getWorld(); + String worldName = multiverseWorld.getName(); + if (!isIrisWorld(multiverseWorld)) { + return; + } + event.setCancelled(true); + String irisName = irisCommandName(multiverseWorld, worldName); + refuse(consumeCommandSender(), + "Multiverse cannot delete or regenerate Iris world " + worldName + + "; it would destroy the world-local pack snapshot.", + String.format(IRIS_REMOVE_HINT, irisName)); + } + + /** + * Multiverse auto-imports every world it finds that is not already in worlds.yml, using its own + * defaults, so an Iris world reconciled out of bukkit.yml arrives with {@code auto-load} and + * {@code adjust-spawn} on. Iris drives its own loading and owns the spawn, so both are turned back off. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onMultiverseWorldImported(MVWorldImportedEvent event) { + MultiverseWorld multiverseWorld = event.getWorld(); + if (!isIrisWorld(multiverseWorld)) { + return; + } + try { + if (!link.applyIrisWorldSettings(multiverseWorld, null)) { + return; + } + WorldManager manager = link.worldManager(); + if (manager != null) { + manager.saveWorldsConfig().get(); + } + IrisLogging.debug("Reset Multiverse auto-load and adjust-spawn for " + multiverseWorld.getName()); + } catch (Throwable failure) { + IrisLogging.warn("Could not apply Iris settings to the Multiverse import of %s: %s", + multiverseWorld.getName(), failure.getClass().getSimpleName()); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onPlayerCommand(PlayerCommandPreprocessEvent event) { + if (inspectCommand(event.getPlayer(), event.getMessage())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onServerCommand(ServerCommandEvent event) { + if (inspectCommand(event.getSender(), event.getCommand())) { + event.setCancelled(true); + } + } + + /** + * Records the sender of a Multiverse command and vetoes a clone that reads from, or writes over, Iris + * storage. + *

+ * Multiverse fires no cancellable event for a clone: it copies the source world folder first and only + * then imports the copy, and the import of an Iris copy always fails. That leaves an orphaned copy of + * the world - frozen pack included - under {@code /dimensions/minecraft/}, which + * Multiverse then advertises as importable, so a later plain {@code mv import} would build a vanilla + * world on top of Iris region files. The command is the only place left to stop it. + *

+ * The destination is checked as well. Multiverse decides a name is free with its own + * {@code hasWorldFolder} check, which resolves the vanilla {@code /} path and never + * looks in the Iris dimension namespace, so a clone can be aimed straight at a live Iris world's slot. + * + * @return true when the command must be cancelled + */ + private boolean inspectCommand(CommandSender sender, String commandLine) { + if (multiverseCommandRoot(commandLine) == null) { + return false; + } + multiverseCommandSender = new WeakReference<>(sender); + String load = multiverseLoad(commandLine); + if (load != null) { + return interceptLoad(sender, load); + } + CloneCommand clone = multiverseClone(commandLine); + if (clone == null) { + return false; + } + if (isIrisWorld(clone.source())) { + refuse(sender, + "Multiverse cannot clone Iris world " + clone.source() + + "; its pack snapshot is world-local and the copy is not a loadable world.", + "Create another Iris world with /iris create type= instead."); + return true; + } + if (clone.destination() != null && isIrisWorld(clone.destination())) { + refuse(sender, + "Multiverse cannot clone onto Iris world " + clone.destination() + + "; that name already owns Iris world storage.", + "Choose another destination name, or free it with /iris remove world=" + + clone.destination() + " delete=true."); + return true; + } + return false; + } + + /** + * Takes over {@code /mv load} for an Iris world Iris can load itself. + *

+ * Multiverse only adopts a world it finds already in Bukkit; otherwise it rebuilds one from a + * WorldCreator carrying the environment it stored. Neither half of that works for an Iris world. The + * stored environment is written from the live world every time Multiverse binds it, and a custom + * dimension reports {@code NORMAL} in the session it was created and {@code CUSTOM} on every later boot, + * so {@code CraftServer.createWorld} refuses it with "Illegal dimension (CUSTOM)". Writing {@code normal} + * instead only trades that for the other failure: Multiverse would build a vanilla world beside the level + * root under the Iris world's name, and the next boot's adoption would fail on the mismatch. + *

+ * So the load is handed to Iris, which creates the world from a keyed creator at + * {@code /dimensions/iris/} with the pack's own environment, and the result is offered + * back to Multiverse. With no loader installed the command is refused rather than passed through, because + * passing it through is the failure this exists to prevent. + * + * @return true when the command must be cancelled + */ + private boolean interceptLoad(CommandSender sender, String worldNameOrAlias) { + String worldName = irisWorldName(worldNameOrAlias); + if (worldName == null || Bukkit.getWorld(worldName) != null) { + // A live world is adopted by Multiverse without a WorldCreator; that path already works. + return false; + } + if (sender != null && !sender.hasPermission(MULTIVERSE_LOAD_PERMISSION)) { + // Taking the command over must never load a world for someone Multiverse would have refused. + return false; + } + ManagedWorldLoader loader = IrisServices.getOrNull(ManagedWorldLoader.class); + if (loader == null) { + refuse(sender, + "Iris owns loading for " + worldName + "; Multiverse would build a vanilla world in its place.", + "Load it with /iris load " + worldName + ", or restart the server."); + return true; + } + link.prepareOwnedWorldLoad(worldName); + IrisLogging.info("Multiverse load of %s handed to Iris.", worldName); + WeakReference target = new WeakReference<>(sender); + try { + // The load settles off the main thread, and handing the result to Multiverse fires a Bukkit + // event, so the whole tail runs back on the main thread exactly as /iris load's does. + loader.load(worldName).whenComplete((result, failure) -> + J.s(() -> settleLoad(target.get(), worldName, result, failure))); + } catch (Throwable failure) { + refuse(sender, "Iris could not load " + worldName + ": " + describe(failure), + "Load it with /iris load " + worldName + ", or restart the server."); + } + return true; + } + + void settleLoad( + CommandSender sender, + String worldName, + ManagedWorldLoader.ManagedWorldLoad result, + Throwable failure + ) { + if (failure != null || result == null || !result.loaded()) { + String detail = failure != null + ? describe(failure) + : result == null ? "the load completed without a result" : result.message(); + refuse(sender, "Iris could not load " + worldName + ": " + detail, + "Check the Iris log, then retry with /iris load " + worldName + "."); + return; + } + boolean adopted = link.adoptOwnedWorld(worldName); + IrisLogging.info("Iris loaded %s; multiverse adopted=%s.", worldName, adopted); + announce(sender, "Iris loaded " + worldName + "."); + } + + /** + * The Bukkit startup name of the Iris world a Multiverse world argument names, or null when it names no + * Iris world. Multiverse accepts its own alias, so the argument is resolved through Multiverse first and + * the name Multiverse recorded is what Iris is asked for. + */ + private String irisWorldName(String worldNameOrAlias) { + if (isIrisOwnedName(worldNameOrAlias)) { + return startupName(worldNameOrAlias); + } + WorldManager manager = link.worldManager(); + if (manager == null) { + return null; + } + MultiverseWorld multiverseWorld = manager.getWorldByNameOrAlias(worldNameOrAlias) + .getOrElse((MultiverseWorld) null); + if (multiverseWorld == null) { + return null; + } + String recorded = multiverseWorld.getName(); + return isIrisOwnedName(recorded) ? startupName(recorded) : null; + } + + /** + * The Bukkit startup name for an Iris world named in any of the forms Iris answers to - the startup name + * itself, the bare key, or {@code iris:} - so the live-world check and the loader both see the one + * name the world is actually registered under. + */ + private static String startupName(String worldName) { + try { + String levelName = IrisWorldStorage.levelRoot().getName(); + return IrisWorldStorage.configuredWorldName( + IrisWorldStorage.managedKeyFromName(worldName, levelName), + levelName); + } catch (Throwable notAnIrisName) { + return worldName; + } + } + + /** + * The world argument of a Multiverse load command, or null when the line is not one. Syntax is + * {@code /mv load [flags]} with the legacy alias {@code /mvload}. + */ + static String multiverseLoad(String commandLine) { + String root = multiverseCommandRoot(commandLine); + if (root == null) { + return null; + } + String line = commandLine.trim(); + if (line.startsWith("/")) { + line = line.substring(1); + } + String[] parts = line.split("\\s+"); + if ("mv".equals(root)) { + return parts.length >= 3 && "load".equalsIgnoreCase(parts[1]) ? worldArgument(parts, 2) : null; + } + if ("mvload".equals(root)) { + return parts.length >= 2 ? worldArgument(parts, 1) : null; + } + return null; + } + + /** + * The Multiverse command root, or null when the line is not one. Multiverse registers {@code /mv} plus + * per-command legacy aliases; anything Iris does not recognise is left alone, which is the behaviour a + * server without this listener already has. + */ + static String multiverseCommandRoot(String commandLine) { + if (commandLine == null) { + return null; + } + String line = commandLine.trim(); + if (line.startsWith("/")) { + line = line.substring(1); + } + int firstSpace = line.indexOf(' '); + String root = (firstSpace < 0 ? line : line.substring(0, firstSpace)).toLowerCase(Locale.ENGLISH); + // Bukkit always accepts the "plugin:command" form as well. + int namespace = root.lastIndexOf(':'); + if (namespace >= 0) { + root = root.substring(namespace + 1); + } + // Every Multiverse alias starts with "mv". Matching the prefix rather than an enumeration keeps a + // new legacy alias from silently escaping the guard; a false match only stores a sender reference + // that nothing but a Multiverse world event ever reads. + return root.startsWith("mv") ? root : null; + } + + /** + * The source and destination world arguments of a Multiverse clone command, or null when the line is + * not one. Syntax is {@code /mv clone [flags]} with the legacy aliases + * {@code /mvcl} and {@code /mvclone}. The destination is null when the admin has not typed it yet. + */ + static CloneCommand multiverseClone(String commandLine) { + String root = multiverseCommandRoot(commandLine); + if (root == null) { + return null; + } + String line = commandLine.trim(); + if (line.startsWith("/")) { + line = line.substring(1); + } + String[] parts = line.split("\\s+"); + int worldIndex; + if ("mv".equals(root)) { + if (parts.length < 3 || !"clone".equalsIgnoreCase(parts[1])) { + return null; + } + worldIndex = 2; + } else if ("mvcl".equals(root) || "mvclone".equals(root)) { + if (parts.length < 2) { + return null; + } + worldIndex = 1; + } else { + return null; + } + String source = worldArgument(parts, worldIndex); + if (source == null) { + return null; + } + return new CloneCommand(source, worldArgument(parts, worldIndex + 1)); + } + + /** + * A world-name argument, or null when it is absent or a flag. Multiverse quotes names containing + * spaces; the guard only needs the leading token, so the opening quote is dropped and the rest kept. + */ + private static String worldArgument(String[] parts, int index) { + if (index >= parts.length) { + return null; + } + String world = parts[index]; + if (world.startsWith("-")) { + return null; + } + if (world.startsWith("\"")) { + world = world.substring(1); + } + return world.isEmpty() ? null : world; + } + + record CloneCommand(String source, String destination) { + CloneCommand { + Objects.requireNonNull(source, "source"); + } + } + + private boolean isIrisWorld(MultiverseWorld multiverseWorld) { + LoadedMultiverseWorld loaded = multiverseWorld.asLoadedWorld().getOrNull(); + World bukkitWorld = loaded == null ? null : loaded.getBukkitWorld().getOrNull(); + if (bukkitWorld != null && IrisToolbelt.isIrisWorld(bukkitWorld)) { + return true; + } + return isIrisOwnedName(multiverseWorld.getName()); + } + + private boolean isIrisWorld(String worldNameOrAlias) { + World live = Bukkit.getWorld(worldNameOrAlias); + if (live != null && IrisToolbelt.isIrisWorld(live)) { + return true; + } + WorldManager manager = link.worldManager(); + if (manager != null) { + MultiverseWorld multiverseWorld = manager.getWorldByNameOrAlias(worldNameOrAlias) + .getOrElse((MultiverseWorld) null); + if (multiverseWorld != null) { + return isIrisWorld(multiverseWorld); + } + } + return isIrisOwnedName(worldNameOrAlias); + } + + private static boolean isIrisOwnedName(String worldName) { + try { + return MultiverseCoreLink.isIrisOwnedWorldName(worldName, IrisWorldStorage.levelRoot()); + } catch (Throwable unavailable) { + return false; + } + } + + /** + * The name {@code /iris remove} accepts. Multiverse reports the name it recorded at import, which is + * the Bukkit startup name for every world Iris registered. + */ + private static String irisCommandName(MultiverseWorld multiverseWorld, String fallback) { + LoadedMultiverseWorld loaded = multiverseWorld.asLoadedWorld().getOrNull(); + World bukkitWorld = loaded == null ? null : loaded.getBukkitWorld().getOrNull(); + return bukkitWorld == null ? fallback : bukkitWorld.getName(); + } + + private CommandSender consumeCommandSender() { + WeakReference captured = multiverseCommandSender; + multiverseCommandSender = null; + return captured == null ? null : captured.get(); + } + + /** + * Multiverse reports a cancelled destructive command as its generic failure string, so the refusal has + * to reach the sender from here or the admin only sees "something went wrong". + */ + private static void refuse(CommandSender sender, String reason, String remedy) { + // The refusal is the guard working, and the admin who typed the command is told directly. This is + // the console's copy of the same sentence, not a warning about the state of the server. + IrisLogging.info("%s %s", reason, remedy); + announce(sender, reason, remedy); + } + + private static void announce(CommandSender sender, String... lines) { + if (sender == null || sender instanceof ConsoleCommandSender) { + // The console already saw it through the Iris log. + return; + } + try { + VolmitSender target = new VolmitSender(sender); + for (String line : lines) { + target.sendMessage(line); + } + } catch (Throwable ignored) { + for (String line : lines) { + sender.sendMessage(line); + } + } + } + + private static String describe(Throwable failure) { + Throwable cause = failure instanceof CompletionException && failure.getCause() != null + ? failure.getCause() + : failure; + String message = cause.getMessage(); + return message == null || message.isBlank() ? cause.getClass().getSimpleName() : message; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index 884004336..9cfe48269 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -193,10 +193,6 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { return m; } - private static void printData(ResourceLoader rl) { - IrisLogging.warn(" " + rl.getResourceTypeName() + " @ /" + rl.getFolderName() + ": Cache=" + rl.getLoadCache().getSize() + " Folders=" + rl.getFolders().size()); - } - public static IrisObject loadAnyObject(String key, @Nullable IrisData nearest) { return loadAny(IrisObject.class, key, nearest); } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java index c4a88a0b6..109eea2d5 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java @@ -250,7 +250,7 @@ public class IrisPregenerator { if (failedCount > 0) { IrisLogging.warn("Pregen finished with " + Form.f(failedCount) + " failed chunk(s); failures are not cached, rerun to fill them"); } - IrisLogging.info("Pregen finished: generated=" + Form.f(generated.get()) + IrisLogging.notice("Pregen finished: generated=" + Form.f(generated.get()) + " total=" + Form.f(totalChunks.get()) + " failed=" + Form.f(failedCount) + " duration=" + Form.duration((long) stopwatch.getMilliseconds()) diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java b/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java index 032755be3..03ea5d2eb 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java @@ -39,7 +39,9 @@ public final class MantleHeapPressure { System::currentTimeMillis, System::gc, () -> invokeHotSpotDiagnosticGc(ManagementFactory.getPlatformMBeanServer()), - (double fraction) -> IrisLogging.warn( + // An escalation step in the reclaim policy, rate limited by that policy rather than by + // anything the operator did. What is actionable is the wait budget being exceeded. + (double fraction) -> IrisLogging.info( "Iris heap remained at %.1f%% after normal panic reclaim; invoking the current JVM's diagnostic full GC to keep generation live.", fraction * 100.0D), (String context, Throwable failure) -> IrisLogging.reportError(context, failure))); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java index 834c6f5be..39152f161 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java @@ -102,7 +102,9 @@ public final class PregenMantleBackpressure { long logNow = M.ms(); if (logNow - lastLog >= 5_000L) { lastLog = logNow; - IrisLogging.warn("Pregen mantle backpressure: " + resident + " tectonic plates resident (hard cap " + hardCap + // Pausing to stay under the plate cap is the design working, and it is reported every five + // seconds for the whole duration of a large pregen. Only exceeding the budget is a warning. + IrisLogging.info("Pregen mantle backpressure: " + resident + " tectonic plates resident (hard cap " + hardCap + "), freed " + freed + " last pass, waited " + elapsed + "ms."); } @@ -147,7 +149,7 @@ public final class PregenMantleBackpressure { long logNow = M.ms(); if (logNow - lastLog >= 5_000L) { lastLog = logNow; - IrisLogging.warn("Pregen heap pressure: pausing generation at " + IrisLogging.info("Pregen heap pressure: pausing generation at " + Math.round(MantleHeapPressure.usedFraction() * 100.0D) + "% heap; evicting tectonic plates and waiting for headroom" + (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + "."); } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java b/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java index e6a17c91e..f89b9ec23 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/cache/PregenCacheImpl.java @@ -40,6 +40,7 @@ public class PregenCacheImpl implements PregenCache { private final File directory; private final int maxSize; + private final AtomicBoolean missingStorageReported = new AtomicBoolean(); private final Long2ObjectLinkedOpenHashMap cache = new Long2ObjectLinkedOpenHashMap<>(); public PregenCacheImpl(File directory, int maxSize) { @@ -183,6 +184,9 @@ public class PregenCacheImpl implements PregenCache { if (!plate.dirty) { return; } + if (!prepareCacheDirectory()) { + return; + } File file = null; try { @@ -196,10 +200,29 @@ public class PregenCacheImpl implements PregenCache { } } - private File fileForPlate(int x, int z) { - if (!directory.exists() && !directory.mkdirs()) { + /** + * Creates the cache directory under an existing parent only. This is a cache: when the storage it lives + * in has been deleted the plate is dropped rather than rebuilt, so a trim cannot resurrect a world tree. + */ + private boolean prepareCacheDirectory() { + if (directory.isDirectory()) { + return true; + } + File parent = directory.getParentFile(); + if (parent == null || !parent.isDirectory()) { + if (missingStorageReported.compareAndSet(false, true)) { + IrisLogging.warn("Pregen cache storage is gone at %s; cached plates are being dropped.", + directory.getAbsolutePath()); + } + return false; + } + if (!directory.mkdirs() && !directory.isDirectory()) { throw new IllegalStateException("Cannot create directory: " + directory.getAbsolutePath()); } + return true; + } + + private File fileForPlate(int x, int z) { return new File(directory, "c." + x + "." + z + ".lz4b"); } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java index ada2c93fc..a56aa24a7 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java @@ -518,7 +518,9 @@ public class AsyncPregenMethod implements PregeneratorMethod { int suppressed = suppressedSlowRequestLogs.getAndSet(0); String suppressedText = suppressed <= 0 ? "" : " suppressed=" + suppressed; - IrisLogging.warn("Async pregen chunk load at " + x + "," + z + // Slow chunk loads are what the adaptive in flight limit exists to absorb, and this line is already + // interval throttled with a suppression count. It reports the throttle working, not a fault. + IrisLogging.info("Async pregen chunk load at " + x + "," + z + " is still pending after " + slowRequestWarningSeconds + "s." + " adaptiveLimit=" + adaptiveInFlightLimit.get() + suppressedText + " " + metricsSnapshot()); diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java b/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java index 52600e294..92232b937 100644 --- a/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java +++ b/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java @@ -86,7 +86,9 @@ public final class IrisSafeguard { } for (ValueWithDiagnostics value : results.values()) { - value.log(true, true); + // Without the stack trace: Diagnostic.Logger splits on newlines, so a trace became one log + // record per frame at the diagnostic's own severity. Traces go through reportError. + value.log(true, false); } } @@ -98,20 +100,18 @@ public final class IrisSafeguard { } } + // A log record carries a level, so a blank record renders as an empty [WARN] line and a rule of + // dashes renders as a [SEVERE] one. Spacing belongs to a console, not to the server log. private static void warning() { IrisLogging.warn(C.GOLD + "Iris is running in Warning Mode"); IrisLogging.warn(C.GRAY + "Some startup checks need attention. Review the messages above for tuning suggestions."); IrisLogging.warn(C.GRAY + "Iris will continue startup normally."); - IrisLogging.warn(""); } private static void unstable() { IrisLogging.error(C.DARK_RED + "Iris is running in Danger Mode"); - IrisLogging.error(""); - IrisLogging.error(C.DARK_GRAY + "--==<" + C.RED + " IMPORTANT " + C.DARK_GRAY + ">==--"); IrisLogging.error("Critical startup checks failed. Review and resolve the errors above as soon as possible."); // No startup sleep: blocking the boot thread protected nothing — world creation and // player admission are already gated by IrisStartupValidation. - IrisLogging.info(""); } } diff --git a/core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java b/core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java new file mode 100644 index 000000000..bb27f660d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java @@ -0,0 +1,109 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.service; + +import art.arcane.iris.core.link.MultiverseCoreLink; +import art.arcane.iris.core.link.MultiverseGuardListener; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.plugin.IrisService; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginDisableEvent; +import org.bukkit.event.server.PluginEnableEvent; + +/** + * Brings Iris' Multiverse integration up exactly when Multiverse is usable and takes it back down when it + * is not. + *

+ * Nothing in this class names a Multiverse type. The guard listener does, and Bukkit resolves a listener's + * handler parameter types at registration, so it is only constructed once Multiverse is enabled. Iris + * declares {@code loadbefore: Multiverse-Core}, which means Multiverse normally enables after Iris and the + * plugin event is the live path; a reload or a plugin manager can invert that, so enablement is checked + * directly as well. + */ +public class MultiverseSVC implements IrisService { + private Listener guard; + + @Override + public void onEnable() { + activate(); + } + + @Override + public void onDisable() { + deactivate(); + } + + @EventHandler + public void onPluginEnable(PluginEnableEvent event) { + if (!MultiverseCoreLink.MULTIVERSE_PLUGIN.equals(event.getPlugin().getName())) { + return; + } + activate(); + } + + @EventHandler + public void onPluginDisable(PluginDisableEvent event) { + if (!MultiverseCoreLink.MULTIVERSE_PLUGIN.equals(event.getPlugin().getName())) { + return; + } + deactivate(); + } + + /** + * Registers the destructive-command guard and re-imposes Iris' intended Multiverse state on every + * world Iris owns. Multiverse imports the worlds it did not know about during its own enable, which is + * before this runs, so the reconciliation pass is the only thing that can correct those entries. + */ + private synchronized void activate() { + if (guard != null) { + return; + } + MultiverseCoreLink link = IrisServices.getOrNull(MultiverseCoreLink.class); + if (link == null || !link.isActive()) { + return; + } + try { + Listener listener = new MultiverseGuardListener(link); + BukkitPlatform.volmitPlugin().registerListener(listener); + guard = listener; + } catch (Throwable failure) { + // A Multiverse whose world events Iris cannot bind to is a Multiverse Iris must not touch. + IrisLogging.warn("Multiverse world events are not bindable (%s); Iris will not guard its" + + " destructive commands.", failure.getClass().getSimpleName()); + return; + } + link.reconcileOwnedWorlds(); + } + + private synchronized void deactivate() { + Listener listener = guard; + if (listener == null) { + return; + } + guard = null; + try { + BukkitPlatform.volmitPlugin().unregisterListener(listener); + } catch (Throwable ignored) { + // Shutdown ordering already unregisters every handler. + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/EngineDataStore.java b/core/src/main/java/art/arcane/iris/engine/EngineDataStore.java index 9e5f2480a..84d54756b 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineDataStore.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineDataStore.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine; +import art.arcane.iris.core.lifecycle.VanishedWorldStorage; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.IrisEngineData; import art.arcane.iris.spi.IrisLogging; @@ -31,6 +32,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -40,8 +42,14 @@ import java.nio.file.StandardCopyOption; * atomic temp-file move so a crash mid-save can never truncate the live engine data. */ final class EngineDataStore { + private static final String ENGINE_DATA_DIRECTORY = "iris/engine-data"; private final IrisEngine engine; private final Object engineDataLock = new Object(); + /** + * Set once this store has written {@code iris/engine-data} into the world folder. From then on that + * directory going missing is a delete, whether or not the server has already put the world folder back. + */ + private volatile boolean engineDataEstablished; EngineDataStore(IrisEngine engine) { this.engine = engine; @@ -57,8 +65,9 @@ final class EngineDataStore { if (loaded != null) { return loaded; } - File f = new File(engine.getWorld().worldFolder(), "iris/engine-data/" + engine.getDimension().getLoadKey() + ".json"); + File f = engineDataFile(); if (f.exists()) { + engineDataEstablished = true; try { loaded = new Gson().fromJson(IO.readAll(f), IrisEngineData.class); if (loaded == null) { @@ -79,12 +88,15 @@ final class EngineDataStore { if (loaded.getStatistics().getVersion() == -1 || loaded.getStatistics().getMCVersion() == -1) { IrisLogging.error("Failed to setup Engine Data!"); } - try { - writeEngineDataAtomically(f, loaded); - } catch (IOException e) { - IrisLogging.reportError(e); - e.printStackTrace(); - throw new IllegalStateException("Failed to create Iris engine data: " + f.getAbsolutePath(), e); + if (!storageVanished()) { + try { + writeEngineDataAtomically(f, loaded); + engineDataEstablished = true; + } catch (IOException e) { + IrisLogging.reportError(e); + e.printStackTrace(); + throw new IllegalStateException("Failed to create Iris engine data: " + f.getAbsolutePath(), e); + } } } engine.engineData = loaded; @@ -92,11 +104,39 @@ final class EngineDataStore { } } + /** + * True when this engine's world storage is gone, which stops persistence rather than letting the write + * rebuild the tree it is supposed to be writing into. + *

+ * The world folder existing is not enough. A {@code save-all} after a hot delete writes the level's own + * {@code data/*.dat} files back and recreates the folder, and Iris' {@code WorldSaveEvent} handler runs + * after that, so a folder check alone lets the save rebuild {@code iris/engine-data} - which is exactly + * the directory the next boot's storage audit reads as "this is an Iris world whose pack snapshot broke". + * Once this store has written that directory, its absence is the delete. + */ + private boolean storageVanished() { + File worldFolder = engine.getWorld().worldFolder(); + if (!engineDataEstablished) { + return VanishedWorldStorage.vanished(worldFolder); + } + return VanishedWorldStorage.vanished(worldFolder, new File(worldFolder, ENGINE_DATA_DIRECTORY)); + } + + private File engineDataFile() { + return new File( + engine.getWorld().worldFolder(), + ENGINE_DATA_DIRECTORY + "/" + engine.getDimension().getLoadKey() + ".json"); + } + void saveEngineData() { synchronized (engineDataLock) { - File f = new File(engine.getWorld().worldFolder(), "iris/engine-data/" + engine.getDimension().getLoadKey() + ".json"); + if (storageVanished()) { + return; + } + File f = engineDataFile(); try { writeEngineDataAtomically(f, engine.getEngineData()); + engineDataEstablished = true; IrisLogging.debug("Saved Engine Data"); } catch (IOException e) { IrisLogging.error("Failed to save Engine Data"); @@ -122,6 +162,13 @@ final class EngineDataStore { if (parent == null) { throw new IOException("Engine data path has no parent: " + output); } + // /iris/engine-data/.json: the world folder itself is never created here, so a + // deleted world cannot be rebuilt by a save that races the guard in saveEngineData. + Path irisRoot = parent.getParent(); + Path worldFolder = irisRoot == null ? null : irisRoot.getParent(); + if (worldFolder == null || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Iris world storage is missing: " + worldFolder); + } Files.createDirectories(parent); Path temporary = Files.createTempFile(parent, output.getFileName().toString(), ".tmp"); try { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index 028022c06..04aef3d5f 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -713,9 +713,12 @@ public class IrisComplex implements DataProvider { } catch (Throwable e) { long now = System.currentTimeMillis(); long last = lastBoundsFailureLog.get(); - if (now - last >= 5000L && lastBoundsFailureLog.compareAndSet(last, now)) { + // The five second gate keeps this off the hot path; the once key keeps it out of a log + // scan for the rest of the generation, where the same cause repeats for every column. + if (now - last >= 5000L && lastBoundsFailureLog.compareAndSet(last, now) + && IrisLogging.warnOnce("biome-bounds:" + e.getClass().getName() + ":" + e.getMessage(), + "Failed to sample interpolated biome bounds at " + xx + " " + zz + ", flattening height to zero: " + e.getClass().getSimpleName() + ": " + e.getMessage())) { IrisLogging.reportError(e); - IrisLogging.warn("Failed to sample interpolated biome bounds at " + xx + " " + zz + ", flattening height to zero: " + e.getClass().getSimpleName() + ": " + e.getMessage()); } } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index d406bff14..5646c1fc3 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -226,7 +226,7 @@ public class IrisEngine implements Engine { IrisLogging.reportError(e); e.printStackTrace(); } - IrisLogging.info("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed()); + IrisLogging.notice("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed()); _t0 = M.ms(); phaseStartedAt = System.nanoTime(); EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(); diff --git a/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java b/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java index b1fac57af..a9e9ca322 100644 --- a/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java +++ b/core/src/main/java/art/arcane/iris/engine/data/cache/AtomicCache.java @@ -28,6 +28,7 @@ public class AtomicCache { private transient final Object initLock = new Object(); private transient final boolean nullSupport; private transient volatile Object value; + private transient volatile Throwable failure; public AtomicCache() { this(false); @@ -40,6 +41,7 @@ public class AtomicCache { public void reset() { synchronized (initLock) { value = null; + failure = null; } } @@ -72,6 +74,10 @@ public class AtomicCache { * Like {@link #aquire(Supplier)} but propagates a supplier failure to the caller instead * of swallowing it into a null return. For values that are mandatory: a caller of a * "@NotNull" accessor should see the supplier's real exception, not a downstream NPE. + *

+ * The supplier is retried on every call until it produces a value, which is what a caller that can + * repair the cause between attempts needs. Use {@link #aquireOnceOrThrow(Supplier)} when re-running a + * failed supplier is itself the problem. */ public T aquireOrThrow(Supplier t) { Object v = value; @@ -96,6 +102,56 @@ public class AtomicCache { } } + /** + * Like {@link #aquireOrThrow(Supplier)} but memoizes the failure as well as the value, so a supplier + * that cannot produce one is run exactly once and every later caller is told the same reason. For + * values whose supplier rebuilds shared state, where re-running it per call repeats that work and + * hides the original cause behind whatever the retry happens to fail on. {@link #reset()} clears it. + */ + public T aquireOnceOrThrow(Supplier t) { + Object v = value; + + if (v != null) { + return unwrap(v); + } + rethrowFailure(failure); + + synchronized (initLock) { + v = value; + + if (v != null) { + return unwrap(v); + } + rethrowFailure(failure); + + T computed; + try { + computed = t.get(); + if (computed == null) { + throw new IllegalStateException("Atomic cache supplier produced null"); + } + } catch (Throwable e) { + failure = e; + throw e; + } + value = computed; + return computed; + } + } + + private static void rethrowFailure(Throwable memoized) { + if (memoized == null) { + return; + } + if (memoized instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (memoized instanceof Error error) { + throw error; + } + throw new IllegalStateException("Atomic cache supplier failed", memoized); + } + public T aquire(Supplier t) { Object v = value; @@ -122,8 +178,12 @@ public class AtomicCache { value = NULL_VALUE; } } catch (Throwable e) { - IrisLogging.error("Atomic cache failure!"); - e.printStackTrace(); + // aquire retries on every call, so a supplier that stays broken is reached per sample. + // The first statement of it carries the stack; the rest are traces of the same cause. + if (IrisLogging.warnOnce("atomic-cache:" + e.getClass().getName() + ":" + e.getMessage(), + "Atomic cache supplier failed: %s: %s", e.getClass().getSimpleName(), e.getMessage())) { + IrisLogging.reportError(e); + } } return null; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java index 7ec5bcf6f..0d706e139 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java @@ -68,7 +68,9 @@ public class WorldObjectPlacer implements IObjectPlacer { if (d instanceof IrisCustomData data) { block.setBlockData(data.getBase(), false); - IrisLogging.warn("Tried to place custom block at " + x + ", " + y + ", " + z + " which is not supported!"); + // Reached per block for every custom block in the pack; the pack is what needs changing, and + // one statement of that is enough. + IrisLogging.warnOnce("custom-block-placer", "Tried to place custom block at " + x + ", " + y + ", " + z + " which is not supported."); } else block.setBlockData(d, false); if (storageChest && !J.runRegion(world, x >> 4, z >> 4, () -> fillLoot(block), 1)) { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java index 48b2c6ed3..a17e4a4a7 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java @@ -341,7 +341,7 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { public MantleChunk acquireChunk(int cx, int cz) { int index = windowIndex(cx, cz); if (index < 0) { - IrisLogging.error("Mantle Writer Accessed chunk out of bounds" + cx + "," + cz); + IrisLogging.debug("Mantle Writer Accessed chunk out of bounds" + cx + "," + cz); return null; } @@ -927,7 +927,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { try { setData(pos.getX(), pos.getY(), pos.getZ(), data); } catch (Throwable e) { - IrisLogging.error("No set? " + data.toString() + " for " + pos.toString()); + // Reached per position while an object writes past the edge of the writer window, which is + // how a large object is clipped. Nothing here is actionable from outside the engine. + IrisLogging.debug("No set? " + data.toString() + " for " + pos.toString()); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java index 4c1e1f0a2..0e8097ccc 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java @@ -276,7 +276,7 @@ public class IrisCompat { searching: while (true) { if (err-- <= 0) { - IrisLogging.error("Can't find block data for " + n); + IrisLogging.warnOnce("compat-block:" + n, "Can't find block data for " + n + "; using STONE."); return BukkitBlockResolution.getNoCompat("STONE"); } String m = buf; @@ -300,7 +300,7 @@ public class IrisCompat { } } - IrisLogging.error("Can't find block data for " + n); + IrisLogging.warnOnce("compat-block:" + n, "Can't find block data for " + n + "; using STONE."); return BukkitBlockResolution.getNoCompat("STONE"); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDecorator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDecorator.java index 8db494a8b..a508ec57f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDecorator.java @@ -182,7 +182,7 @@ public class IrisDecorator { public PlatformBlockState getBlockData100(IrisBiome b, RNG rng, double x, double y, double z, IrisData data) { if (getBlockData(data).isEmpty()) { - IrisLogging.warn("Empty Block Data for " + b.getName()); + IrisLogging.warnOnce("decorator-empty:" + b.getName(), "Empty Block Data for " + b.getName()); return null; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java index b2aa8d026..c16790273 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java @@ -19,13 +19,9 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisRegistrant; -import art.arcane.iris.spi.IrisLogging; -import javax.imageio.ImageIO; import java.awt.Color; import java.awt.image.BufferedImage; -import java.io.File; -import java.io.IOException; import java.util.Objects; public class IrisImage extends IrisRegistrant { @@ -116,21 +112,4 @@ public class IrisImage extends IrisRegistrant { return "Image"; } - public void writeDebug(IrisImageChannel channel) { - - - try { - File at = new File(getLoadFile().getParentFile(), "debug-see-" + getLoadFile().getName()); - BufferedImage b = new BufferedImage(getWidth(), getHeight(), BufferedImage.TYPE_INT_RGB); - for (int i = 0; i < getWidth(); i++) { - for (int j = 0; j < getHeight(); j++) { - b.setRGB(i, j, Color.getHSBColor(0, 0, (float) getValue(channel, i, j)).getRGB()); - } - } - ImageIO.write(b, "png", at); - IrisLogging.warn("Debug image written to " + at.getPath() + " for channel " + channel.name()); - } catch (IOException e) { - e.printStackTrace(); - } - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java index aae29ede6..8638a1fb5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImageMap.java @@ -67,7 +67,9 @@ public class IrisImageMap { public double getNoise(IrisData data, int x, int z) { IrisImage i = imageCache.aquire(() -> data.getImageLoader().load(image)); if (i == null) { - IrisLogging.error("NULL IMAGE FOR " + image); + // Reached per sample for as long as the pack points at an image that will not load, so the + // pack key is what the operator needs and one statement of it is enough. + IrisLogging.warnOnce("image-map:" + image, "No image %s in the pack; that noise samples as zero.", image); return 0; } diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java b/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java index 9d326ac5e..00d18bbc5 100644 --- a/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java +++ b/core/src/main/java/art/arcane/iris/util/common/misc/SlimJar.java @@ -38,7 +38,9 @@ public class SlimJar { .debug(DEBUG) .build(); } catch (Throwable e) { - IrisLogging.warn("Failed to inject the library loader, falling back to application builder"); + // The Spigot builder is a probe: not every server exposes it, and the fallback is the + // supported path on the ones that do not. + IrisLogging.info("Failed to inject the library loader, falling back to application builder"); ApplicationBuilder.appending(plugin.getName()) .injectableFactory(InjectableFactory.selecting(InjectableFactory.ERROR, InjectableFactory.INJECTABLE, InjectableFactory.WRAPPED, InjectableFactory.UNSAFE)) .downloadDirectoryPath(downloadPath) diff --git a/core/src/main/java/art/arcane/iris/util/project/hunk/Hunk.java b/core/src/main/java/art/arcane/iris/util/project/hunk/Hunk.java index 925240adf..57e10df6f 100644 --- a/core/src/main/java/art/arcane/iris/util/project/hunk/Hunk.java +++ b/core/src/main/java/art/arcane/iris/util/project/hunk/Hunk.java @@ -1009,7 +1009,9 @@ public interface Hunk extends HunkLike { */ default void set(int x, int y, int z, T t) { if (!contains(x, y, z)) { - IrisLogging.warn("OUT OF BOUNDS " + x + " " + y + " " + z + " in bounds " + getWidth() + " " + getHeight() + " " + getDepth()); + // The clamp on the default write path: callers write past the edge of a hunk by design and + // rely on the write being dropped, so this is a trace of a normal event, not a problem. + IrisLogging.debug("OUT OF BOUNDS " + x + " " + y + " " + z + " in bounds " + getWidth() + " " + getHeight() + " " + getDepth()); return; } diff --git a/core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java b/core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java new file mode 100644 index 000000000..03913867f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/DiagnosticSeverityPolicyTest.java @@ -0,0 +1,150 @@ +package art.arcane.iris.core; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Every core diagnostic keeps its severity through to the server log, so the level a call site picks is the + * level an operator scanning for WARN and SEVERE sees. This pins the two ends of that decision: conditions an + * operator must act on stay at WARN or ERROR, and conditions that repeat per block, per sample or per chunk - + * or that report designed backpressure, a probe on an optional dependency, or a success - do not. + *

+ * The pin is on the source rather than on a live engine because most of these call sites need a running world + * to reach, and what is being fixed is the choice of level, not the condition that triggers it. + */ +public class DiagnosticSeverityPolicyTest { + @Test + public void perBlockAndPerSampleConditionsDoNotReachTheServerLogAtWarnLevel() { + assertLoggedWith("util/project/hunk/Hunk.java", "OUT OF BOUNDS ", "debug"); + assertLoggedWith("engine/mantle/MantleWriter.java", "No set? ", "debug"); + assertLoggedWith("engine/mantle/MantleWriter.java", "Mantle Writer Accessed chunk out of bounds", "debug"); + assertLoggedWith("engine/object/IrisImageMap.java", "No image ", "warnOnce"); + assertLoggedWith("engine/object/IrisDecorator.java", "Empty Block Data for ", "warnOnce"); + assertLoggedWith("engine/object/IrisCompat.java", "Can't find block data for ", "warnOnce"); + assertLoggedWith("engine/data/cache/AtomicCache.java", "Atomic cache supplier failed: %s: %s", "warnOnce"); + assertLoggedWith("engine/IrisComplex.java", "Failed to sample interpolated biome bounds", "warnOnce"); + assertLoggedWith("engine/framework/placer/WorldObjectPlacer.java", "Tried to place custom block at", "warnOnce"); + } + + /** + * Backpressure pausing generation is the design working. It is reported every five seconds for the whole + * duration of a large pregen, so at WARN it would be the only thing a log scan finds. + */ + @Test + public void designedBackpressureIsReportedWithoutRaisingAWarning() { + assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen mantle backpressure: ", "info"); + assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen heap pressure: pausing generation", "info"); + assertLoggedWith("core/pregenerator/MantleHeapPressure.java", "Iris heap remained at", "info"); + assertLoggedWith("core/pregenerator/methods/AsyncPregenMethod.java", "is still pending after", "info"); + } + + @Test + public void probesOnOptionalDependenciesAreNotWarnings() { + assertLoggedWith("core/link/MultiverseCoreLink.java", "is not reachable; Iris world settings", "info"); + assertLoggedWith("core/link/MultiverseCoreLink.java", "Multiverse will record the live name", "info"); + assertLoggedWith("core/gui/GuiHost.java", "Unable to install the Iris desktop quit guard", "info"); + assertLoggedWith("util/common/misc/SlimJar.java", "Failed to inject the library loader", "info"); + } + + /** + * A refused destructive command is the guard working, and the admin who typed it is told directly. The + * console record of it is not a warning about the server. + */ + @Test + public void aRefusedMultiverseCommandIsRecordedWithoutRaisingAWarning() throws IOException { + String guard = read("core/link/MultiverseGuardListener.java"); + + assertTrue("the console record of a refusal is kept", guard.contains("IrisLogging.info(\"%s %s\", reason, remedy)")); + assertFalse("the refusal is not a warning about the server", guard.contains("IrisLogging.warn(\"%s %s\"")); + } + + @Test + public void decorationAndBlankLinesAreNotEmittedAtAnySeverity() throws IOException { + String safeguard = read("core/safeguard/IrisSafeguard.java"); + assertFalse("a blank record renders as an empty [WARN] line", safeguard.contains("IrisLogging.warn(\"\")")); + assertFalse("a blank record renders as an empty [SEVERE] line", safeguard.contains("IrisLogging.error(\"\")")); + assertFalse("a blank record renders as an empty [INFO] line", safeguard.contains("IrisLogging.info(\"\")")); + assertFalse("a separator rule is not a diagnostic", safeguard.contains("--==<")); + + String configurator = read("core/ServerConfigurator.java"); + assertFalse("a separator rule is not a diagnostic", configurator.contains("IrisLogging.error(\"====")); + assertFalse("a separator rule is not a diagnostic", configurator.contains("IrisLogging.error(\"----")); + } + + /** + * A written debug image is a success, and the method that wrote it had no callers at all. + */ + @Test + public void theUnusedDebugImageWriterIsGone() throws IOException { + assertFalse(read("engine/object/IrisImage.java").contains("writeDebug")); + } + + /** + * The other end of the policy. These are the conditions the promotion exists for. + */ + @Test + public void operatorActionableConditionsKeepTheirSeverity() { + assertLoggedWith("core/lifecycle/VanishedWorldStorage.java", "Iris world storage is gone at", "error"); + assertLoggedWith("core/IrisWorlds.java", "has unusable world storage and is excluded", "error"); + assertLoggedWith("engine/EngineDataStore.java", "Failed to setup Engine Data", "error"); + assertLoggedWith("engine/IrisEngineMantle.java", "Failed to read chunk section, skipping it.", "error"); + assertLoggedWith("engine/IrisEngineMantle.java", "Failed to read chunk, creating a new chunk instead.", "error"); + assertLoggedWith("util/common/reflect/WrappedField.java", "Failed to created WrappedField", "error"); + assertLoggedWith("core/datapack/DatapackIngestService.java", "Repairing modified or corrupt Iris-managed datapack", "warn"); + assertLoggedWith("core/pregenerator/cache/PregenCacheImpl.java", "Pregen cache storage is gone at", "warn"); + assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen mantle backpressure exceeded ", "warn"); + assertLoggedWith("core/pregenerator/PregenMantleBackpressure.java", "Pregen heap pressure wait exceeded ", "warn"); + assertLoggedWith("core/pregenerator/IrisPregenerator.java", "failed chunk(s); failures are not cached", "warn"); + assertLoggedWith("core/service/MultiverseSVC.java", "Multiverse world events are not bindable", "warn"); + } + + /** + * A handful of lifecycle lines an operator reads logs/latest.log to find. NOTICE is the level adapters + * route to the server's own logger rather than to the console sender. + */ + @Test + public void lifecycleLinesAreRaisedAtNoticeLevel() { + assertLoggedWith("engine/IrisEngine.java", "Engine init: ", "notice"); + assertLoggedWith("core/link/MultiverseCoreLink.java", "Adopted %d live Iris world", "notice"); + assertLoggedWith("core/pregenerator/IrisPregenerator.java", "Pregen finished: ", "notice"); + } + + private static void assertLoggedWith(String relativePath, String fragment, String expectedMethod) { + String source; + try { + source = read(relativePath); + } catch (IOException unreadable) { + throw new AssertionError("Cannot read " + relativePath, unreadable); + } + + int occurrences = 0; + for (int at = source.indexOf(fragment); at >= 0; at = source.indexOf(fragment, at + 1)) { + occurrences++; + assertEquals(relativePath + " @ \"" + fragment + "\" occurrence " + occurrences, + expectedMethod, loggingMethodBefore(source, at, relativePath, fragment)); + } + assertTrue(relativePath + " no longer contains \"" + fragment + "\"", occurrences > 0); + } + + private static String loggingMethodBefore(String source, int at, String relativePath, String fragment) { + int call = source.lastIndexOf("IrisLogging.", at); + assertTrue(relativePath + " @ \"" + fragment + "\" is not logged through IrisLogging", call >= 0); + int start = call + "IrisLogging.".length(); + int end = start; + while (end < source.length() && Character.isJavaIdentifierPart(source.charAt(end))) { + end++; + } + return source.substring(start, end); + } + + private static String read(String relativePath) throws IOException { + return Files.readString(Path.of("src/main/java/art/arcane/iris").resolve(relativePath)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java index a77a0a0c6..2f4199e5d 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java @@ -427,4 +427,21 @@ public class IrisWorldStorageTest { Path packRoot = Files.createDirectory(irisRoot.resolve("pack")); assertEquals(packRoot.toFile(), IrisWorldStorage.requireFrozenPackRoot(dimensionRoot)); } + + @Test + public void managedWorldStorageIsOnlyReportedForRealIrisWorldDirectories() throws Exception { + File levelRoot = temporaryFolder.newFolder("managed-storage-level-root"); + + assertFalse(IrisWorldStorage.hasManagedWorldStorage(null)); + assertFalse(IrisWorldStorage.hasManagedWorldStorage(levelRoot)); + + Path namespace = Files.createDirectories(levelRoot.toPath().resolve("dimensions/iris")); + assertFalse(IrisWorldStorage.hasManagedWorldStorage(levelRoot)); + + Files.writeString(namespace.resolve("stray"), "not a world directory"); + assertFalse(IrisWorldStorage.hasManagedWorldStorage(levelRoot)); + + Files.createDirectory(namespace.resolve("moon")); + assertTrue(IrisWorldStorage.hasManagedWorldStorage(levelRoot)); + } } diff --git a/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java b/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java index 32e88c8c0..92d82fc3d 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.core; +import art.arcane.iris.core.lifecycle.MissingWorldStorageLog; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -11,7 +12,9 @@ import java.util.Map; import java.util.Set; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; public class IrisWorldsTest { @Rule @@ -66,6 +69,54 @@ public class IrisWorldsTest { assertNull(IrisWorlds.generatorLoadKey(null, "overworld")); } + @Test + public void orphanedIrisWorldsAreReportedButVanillaSlotsAreNot() throws Exception { + MissingWorldStorageLog.reset(); + Path levelRoot = temporaryFolder.newFolder("orphan-report", "world").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris/present")); + + Map configuredWorlds = new LinkedHashMap<>(); + configuredWorlds.put("world_nether", "underworld"); + configuredWorlds.put("world_iris_present", "overworld"); + configuredWorlds.put("world_iris_gone", "overworld"); + + assertEquals( + Set.of("world_iris_present"), + IrisWorlds.filterBukkitWorldsByStorage(levelRoot, configuredWorlds).keySet()); + assertTrue(MissingWorldStorageLog.hasWarned("world_iris_gone")); + assertFalse("a vanilla slot that was never created is not an orphan", + MissingWorldStorageLog.hasWarned("world_nether")); + assertFalse(MissingWorldStorageLog.hasWarned("world_iris_present")); + MissingWorldStorageLog.reset(); + } + + /** + * The registry is built from a private constructor behind a static cache, so the isolation contract is + * asserted against the source: one unusable world folder used to throw out of {@code clean()}, through + * the constructor and into the cache, which returned null and NPE'd every caller. + */ + @Test + public void oneUnusableWorldIsExcludedInsteadOfFailingTheWholeRegistry() throws Exception { + String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/IrisWorlds.java")); + + int accessor = source.indexOf("public static IrisWorlds get()"); + int accessorEnd = source.indexOf("public synchronized void put(", accessor); + assertTrue("get() must not swallow a failure into a null return", + source.substring(accessor, accessorEnd).contains("cache.aquireOnceOrThrow(")); + + int clean = source.indexOf("public synchronized void clean()"); + int cleanEnd = source.indexOf("public synchronized void save()", clean); + String cleanBody = source.substring(clean, cleanEnd); + assertTrue("clean() must isolate an unusable entry", + cleanBody.contains("catch (IllegalStateException e)")); + assertTrue("an unusable entry stays in the registry so /iris remove can still find it", + cleanBody.contains("warnUnusableStorage(entry.getKey(), e);")); + + int loadDimension = source.indexOf("private IrisDimension loadDimension("); + assertTrue("loadDimension must exclude an unusable world rather than propagate", + source.substring(loadDimension).contains("catch (IllegalStateException unusableStorage)")); + } + @Test public void bukkitWorldFilteringRecognizesCurrentCraftBukkitConfiguredStorage() throws Exception { Path worldContainer = temporaryFolder.newFolder("configured-server").toPath(); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java index 0807b226b..a7ae182ac 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java @@ -151,6 +151,186 @@ public class BukkitWorldConfigurationTest { ).isEmpty()); } + @Test + public void auditSeparatesUsableStorageFromOrphansAndBrokenSnapshots() throws Exception { + File configuration = temporaryFolder.newFile("audit-bukkit.yml"); + Path levelRoot = temporaryFolder.newFolder("audit-level-root").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris/healthy/iris/pack")); + Files.createDirectories(levelRoot.resolve("dimensions/iris/broken/region")); + Files.writeString(levelRoot.resolve("dimensions/iris/broken/region/r.0.0.mca"), "terrain"); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world_iris_healthy.generator", "Iris:overworld"); + yaml.set("worlds.world_iris_broken.generator", "Iris:overworld"); + yaml.set("worlds.world_iris_gone.generator", "Iris:overworld"); + yaml.save(configuration); + + List audited = + BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot); + + assertEquals(3, audited.size()); + assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, stateOf(audited, "world_iris_broken")); + assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.MISSING, stateOf(audited, "world_iris_gone")); + assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.PRESENT, stateOf(audited, "world_iris_healthy")); + assertEquals( + levelRoot.resolve("dimensions/iris/broken").toAbsolutePath().normalize(), + entryOf(audited, "world_iris_broken").storagePath()); + assertTrue(entryOf(audited, "world_iris_broken").detail().contains("pack")); + } + + @Test + public void auditTreatsANonDirectoryStorageSlotAsAnOrphanTheServerNeverLoads() throws Exception { + File configuration = temporaryFolder.newFile("audit-file-slot.yml"); + Path levelRoot = temporaryFolder.newFolder("audit-file-level-root").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris")); + Files.writeString(levelRoot.resolve("dimensions/iris/file"), "not a world directory"); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world_iris_file.generator", "Iris:overworld"); + yaml.save(configuration); + + assertEquals( + BukkitWorldConfiguration.IrisWorldStorageState.MISSING, + stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot), + "world_iris_file")); + } + + /** + * Deleting a live world's folder and letting the server save it back leaves a five-file data/ skeleton + * with no regions and no iris/ directory. Nothing in it can be overwritten, so it must not hold the + * server hostage on the next boot. + */ + @Test + public void auditTreatsAServerRewrittenHuskAsAnOrphan() throws Exception { + File configuration = temporaryFolder.newFile("audit-husk.yml"); + Path levelRoot = temporaryFolder.newFolder("audit-husk-level-root").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris/husk/data/paper")); + Files.createDirectories(levelRoot.resolve("dimensions/iris/husk/data/minecraft")); + Files.writeString(levelRoot.resolve("dimensions/iris/husk/data/paper/level_overrides.dat"), "x"); + Files.writeString(levelRoot.resolve("dimensions/iris/husk/data/minecraft/raids.dat"), "x"); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world_iris_husk.generator", "Iris:overworld"); + yaml.save(configuration); + + List audited = + BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot); + + assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.EMPTY, stateOf(audited, "world_iris_husk")); + assertEquals( + levelRoot.resolve("dimensions/iris/husk").toAbsolutePath().normalize(), + entryOf(audited, "world_iris_husk").storagePath()); + } + + @Test + public void auditFailsClosedForMantleDataWithoutAPackSnapshot() throws Exception { + File configuration = temporaryFolder.newFile("audit-mantle.yml"); + Path levelRoot = temporaryFolder.newFolder("audit-mantle-level-root").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris/mantled/mantle")); + Files.writeString(levelRoot.resolve("dimensions/iris/mantled/mantle/0.0.ttp"), "mantle"); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world_iris_mantled.generator", "Iris:overworld"); + yaml.save(configuration); + + assertEquals( + BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, + stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot), + "world_iris_mantled")); + } + + /** + * An iris/ directory without iris/pack is an Iris world whose pack snapshot broke, not an empty folder. + */ + @Test + public void auditFailsClosedForAnIrisMarkerWithoutAPackSnapshot() throws Exception { + File configuration = temporaryFolder.newFile("audit-marker.yml"); + Path levelRoot = temporaryFolder.newFolder("audit-marker-level-root").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris/marked/iris/engine-data")); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world_iris_marked.generator", "Iris:overworld"); + yaml.save(configuration); + + assertEquals( + BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, + stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot), + "world_iris_marked")); + } + + /** + * IrisWorldStorage refuses to resolve through a symbolic link, so a linked dimension path is storage Iris + * cannot use - not storage that is not there. Reporting it as a cold orphan tells the operator to restore + * a folder that is already present. + */ + @Test + public void auditFailsClosedForASymlinkedDimensionPath() throws Exception { + File configuration = temporaryFolder.newFile("audit-symlink.yml"); + Path levelRoot = temporaryFolder.newFolder("audit-symlink-level-root").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris")); + Path elsewhere = temporaryFolder.newFolder("audit-symlink-target").toPath(); + Files.createDirectories(elsewhere.resolve("iris/pack")); + try { + Files.createSymbolicLink(levelRoot.resolve("dimensions/iris/linked"), elsewhere); + } catch (IOException | UnsupportedOperationException unsupported) { + assumeNoException("symbolic links are not creatable on this filesystem", unsupported); + } + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world_iris_linked.generator", "Iris:overworld"); + yaml.save(configuration); + + List audited = + BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot); + + assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, stateOf(audited, "world_iris_linked")); + assertTrue(entryOf(audited, "world_iris_linked").detail(), + entryOf(audited, "world_iris_linked").detail().contains("symbolic link")); + } + + @Test + public void auditStillReportsATrulyAbsentDimensionPathAsAnOrphan() throws Exception { + File configuration = temporaryFolder.newFile("audit-absent.yml"); + Path levelRoot = temporaryFolder.newFolder("audit-absent-level-root").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris")); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world_iris_absent.generator", "Iris:overworld"); + yaml.save(configuration); + + assertEquals( + BukkitWorldConfiguration.IrisWorldStorageState.MISSING, + stateOf(BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot), + "world_iris_absent")); + } + + @Test + public void missingStorageIsReportedOncePerWorld() throws Exception { + MissingWorldStorageLog.reset(); + File configuration = temporaryFolder.newFile("orphan-warning-bukkit.yml"); + Path levelRoot = temporaryFolder.newFolder("orphan-warning-level-root").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris")); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world_iris_gone.generator", "Iris:overworld"); + yaml.save(configuration); + + assertFalse(MissingWorldStorageLog.hasWarned("world_iris_gone")); + BukkitWorldConfiguration.readIrisGeneratorBindings(configuration, "world", levelRoot); + + assertTrue(MissingWorldStorageLog.hasWarned("world_iris_gone")); + MissingWorldStorageLog.reset(); + } + + private static BukkitWorldConfiguration.IrisWorldStorageState stateOf( + List entries, + String configuredWorldName + ) { + return entryOf(entries, configuredWorldName).state(); + } + + private static BukkitWorldConfiguration.IrisWorldStorageEntry entryOf( + List entries, + String configuredWorldName + ) { + return entries.stream() + .filter(entry -> entry.configuredWorldName().equals(configuredWorldName)) + .findFirst() + .orElseThrow(() -> new AssertionError("No audit entry for " + configuredWorldName)); + } + @Test public void registersAndRemovesWorldAtomically() throws Exception { File configuration = temporaryFolder.newFile("bukkit.yml"); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java new file mode 100644 index 000000000..6cc517f79 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java @@ -0,0 +1,269 @@ +package art.arcane.iris.core.lifecycle; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/** + * Deleting a loaded Iris world's folder and letting the server save the level back leaves a directory the + * server still enumerates but that owns nothing: no pack snapshot, no chunk data. Left in the dimensions + * tree it stops startup either way - Iris refuses to boot over it, or excluding it trips Paper's interactive + * world-migration gate - so the bootstrap has to move it out before any level is created. + */ +public class HuskWorldQuarantineTest { + private static final Instant STAMP = Instant.parse("2026-08-20T04:05:06Z"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void aServerSkeletonWithNoPackAndNoChunkDataIsMovedOutOfTheDimensionsTree() throws IOException { + Path levelRoot = level("husk"); + Path husk = dimensionRoot(levelRoot, "irisworld"); + write(husk.resolve("data/paper/level_overrides.dat")); + write(husk.resolve("data/minecraft/raids.dat")); + + List warnings = new ArrayList<>(); + List moved = + HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warnings::add, STAMP); + + assertEquals(1, moved.size()); + assertFalse("the server must not enumerate the husk", Files.exists(husk, LinkOption.NOFOLLOW_LINKS)); + Path destination = levelRoot.resolve("iris/husks/irisworld-20260820T040506"); + assertEquals(destination, moved.getFirst().destination()); + assertTrue(Files.isRegularFile(destination.resolve("data/paper/level_overrides.dat"))); + assertEquals(1, warnings.size()); + String warning = warnings.getFirst(); + assertTrue(warning, warning.contains(husk.toString())); + assertTrue(warning, warning.contains(destination.toString())); + assertTrue(warning, warning.contains("/iris remove world=world_iris_irisworld delete=true")); + } + + @Test + public void aWorldWithRegionDataIsLeftForTheFailClosedGuard() throws IOException { + Path levelRoot = level("region"); + Path world = dimensionRoot(levelRoot, "irisworld"); + write(world.resolve("region/r.0.0.mca")); + + assertQuarantinesNothing(levelRoot, world); + } + + @Test + public void aWorldWithEntitiesOrPoiDataIsLeftForTheFailClosedGuard() throws IOException { + Path levelRoot = level("entities"); + Path entities = dimensionRoot(levelRoot, "onlyentities"); + write(entities.resolve("entities/r.0.0.mca")); + Path poi = dimensionRoot(levelRoot, "onlypoi"); + write(poi.resolve("poi/r.0.0.mca")); + + List moved = + HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> { + }, STAMP); + + assertTrue(moved.isEmpty()); + assertTrue(Files.isDirectory(entities)); + assertTrue(Files.isDirectory(poi)); + } + + @Test + public void aWorldWithMantleDataIsLeftForTheFailClosedGuard() throws IOException { + Path levelRoot = level("mantle"); + Path world = dimensionRoot(levelRoot, "irisworld"); + write(world.resolve("mantle/pv.0.ttp.lz4b")); + + assertQuarantinesNothing(levelRoot, world); + } + + @Test + public void anIrisMarkerDirectoryIsLeftForTheFailClosedGuard() throws IOException { + Path levelRoot = level("marker"); + Path world = dimensionRoot(levelRoot, "irisworld"); + Files.createDirectories(world.resolve("iris/engine-data")); + write(world.resolve("data/paper/level_overrides.dat")); + + assertQuarantinesNothing(levelRoot, world); + } + + @Test + public void aFrozenPackSnapshotIsLeftAlone() throws IOException { + Path levelRoot = level("pack"); + Path world = dimensionRoot(levelRoot, "irisworld"); + Files.createDirectories(world.resolve("iris/pack/dimensions")); + + assertQuarantinesNothing(levelRoot, world); + } + + /** + * macOS recreates a directory to hold a .DS_Store while a delete is still in flight, so a husk whose + * iris/ folder holds nothing but that file is exactly as worthless as one with no iris/ folder at all. + */ + @Test + public void anIrisFolderHoldingOnlyOsMetadataIsTreatedAsAbsent() throws IOException { + Path levelRoot = level("dsstore"); + Path world = dimensionRoot(levelRoot, "irisworld"); + write(world.resolve("iris/.DS_Store")); + write(world.resolve("data/paper/level_overrides.dat")); + + List moved = + HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> { + }, STAMP); + + assertEquals(1, moved.size()); + assertFalse("the server must not enumerate the husk", Files.exists(world, LinkOption.NOFOLLOW_LINKS)); + } + + @Test + public void everyOsMetadataFileCountsAsAbsent() throws IOException { + Path levelRoot = level("osmetadata"); + Path world = dimensionRoot(levelRoot, "irisworld"); + write(world.resolve("iris/.DS_Store")); + write(world.resolve("iris/Thumbs.db")); + write(world.resolve("iris/desktop.ini")); + write(world.resolve("iris/._pack")); + + List moved = + HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> { + }, STAMP); + + assertEquals(1, moved.size()); + } + + @Test + public void anIrisFolderWithRealContentBesideOsMetadataIsLeftForTheFailClosedGuard() throws IOException { + Path levelRoot = level("dsstore-and-pack"); + Path world = dimensionRoot(levelRoot, "irisworld"); + write(world.resolve("iris/.DS_Store")); + Files.createDirectories(world.resolve("iris/pack/dimensions")); + + assertQuarantinesNothing(levelRoot, world); + } + + @Test + public void anIrisFolderHoldingOnlyAnEmptySubdirectoryIsLeftForTheFailClosedGuard() throws IOException { + Path levelRoot = level("dsstore-and-dir"); + Path world = dimensionRoot(levelRoot, "irisworld"); + write(world.resolve("iris/.DS_Store")); + Files.createDirectories(world.resolve("iris/engine-data")); + + assertQuarantinesNothing(levelRoot, world); + } + + @Test + public void aSymlinkedIrisFolderIsLeftForTheFailClosedGuard() throws IOException { + Path levelRoot = level("iris-symlink"); + Path world = dimensionRoot(levelRoot, "irisworld"); + Path elsewhere = temporaryFolder.newFolder("iris-symlink-target").toPath(); + try { + Files.createSymbolicLink(world.resolve("iris"), elsewhere); + } catch (IOException | UnsupportedOperationException unsupported) { + assumeTrue("symbolic links are not creatable on this filesystem", false); + } + + assertQuarantinesNothing(levelRoot, world); + } + + @Test + public void aSymlinkedDimensionRootIsNeverMoved() throws IOException { + Path levelRoot = level("symlink"); + Path namespace = levelRoot.resolve("dimensions/iris"); + Files.createDirectories(namespace); + Path elsewhere = temporaryFolder.newFolder("symlink-target").toPath(); + write(elsewhere.resolve("data/paper/level_overrides.dat")); + Path link = namespace.resolve("irisworld"); + try { + Files.createSymbolicLink(link, elsewhere); + } catch (IOException | UnsupportedOperationException unsupported) { + assumeTrue("symbolic links are not creatable on this filesystem", false); + } + + List moved = + HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> { + }, STAMP); + + assertTrue(moved.isEmpty()); + assertTrue(Files.isSymbolicLink(link)); + assertTrue(Files.isRegularFile(elsewhere.resolve("data/paper/level_overrides.dat"))); + } + + @Test + public void anUnreadableDimensionRootIsNeverMoved() throws IOException { + Path levelRoot = level("unreadable"); + Path world = dimensionRoot(levelRoot, "irisworld"); + write(world.resolve("data/paper/level_overrides.dat")); + assumeTrue("posix permissions are required to make a directory unreadable", + Files.getFileStore(world).supportsFileAttributeView("posix")); + Files.setPosixFilePermissions(world, Set.of()); + try { + assumeTrue("running as root defeats the permission bits", !Files.isReadable(world)); + List moved = + HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> { + }, STAMP); + + assertTrue(moved.isEmpty()); + } finally { + Files.setPosixFilePermissions(world, java.nio.file.attribute.PosixFilePermissions.fromString("rwx------")); + } + } + + @Test + public void aSecondHuskInTheSameSecondGetsItsOwnDestination() throws IOException { + Path levelRoot = level("collision"); + Path first = dimensionRoot(levelRoot, "irisworld"); + write(first.resolve("data/paper/level_overrides.dat")); + Files.createDirectories(levelRoot.resolve("iris/husks/irisworld-20260820T040506")); + + List moved = + HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> { + }, STAMP); + + assertEquals(1, moved.size()); + assertEquals(levelRoot.resolve("iris/husks/irisworld-20260820T040506-2"), moved.getFirst().destination()); + } + + @Test + public void aLevelWithNoIrisNamespaceIsAnEmptySweep() throws IOException { + Path levelRoot = level("none"); + + assertTrue(HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warning -> { + }, STAMP).isEmpty()); + } + + private void assertQuarantinesNothing(Path levelRoot, Path world) { + List warnings = new ArrayList<>(); + List moved = + HuskWorldQuarantine.quarantineWorthlessHusks(levelRoot, warnings::add, STAMP); + + assertTrue(moved.isEmpty()); + assertTrue(warnings.isEmpty()); + assertTrue(Files.isDirectory(world, LinkOption.NOFOLLOW_LINKS)); + } + + private Path level(String scope) throws IOException { + return temporaryFolder.newFolder(scope, "world").toPath(); + } + + private static Path dimensionRoot(Path levelRoot, String key) throws IOException { + Path root = levelRoot.resolve("dimensions/iris").resolve(key); + Files.createDirectories(root); + return root; + } + + private static void write(Path file) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, "x"); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLogTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLogTest.java new file mode 100644 index 000000000..8c6c4d1e5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/MissingWorldStorageLogTest.java @@ -0,0 +1,123 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.LogLevel; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + +/** + * Paper runs the plugin bootstrap before any plugin logger exists, so a warning raised there reaches the + * console and the runtime log but never the instance's logs/latest.log - the only log most operators read. + */ +public class MissingWorldStorageLogTest { + private final List emitted = new ArrayList<>(); + private final List levels = new ArrayList<>(); + private IrisPlatform previousPlatform; + + @Before + public void captureLog() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + MissingWorldStorageLog.reset(); + emitted.clear(); + levels.clear(); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + MissingWorldStorageLog.reset(); + } + + @Test + public void bootstrapWarningsAreReplayedOnceThePlatformLogIsUp() { + MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone")); + assertTrue(MissingWorldStorageLog.hasWarned("world_iris_gone")); + + bindCapturingPlatform(); + MissingWorldStorageLog.replayToPlatformLog(); + + assertEquals(2, emitted.size()); + assertTrue(emitted.get(0), emitted.get(0).contains("world_iris_gone")); + assertTrue(emitted.get(0), emitted.get(0).contains("/srv/world/dimensions/iris/gone")); + } + + @Test + public void replayIsIdempotentAndNeverRepeatsAWarningThePlatformAlreadySaw() { + MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone")); + bindCapturingPlatform(); + MissingWorldStorageLog.replayToPlatformLog(); + emitted.clear(); + + MissingWorldStorageLog.replayToPlatformLog(); + MissingWorldStorageLog.warnOnce("world_iris_later", Path.of("/srv/world/dimensions/iris/later")); + int afterLiveWarning = emitted.size(); + MissingWorldStorageLog.replayToPlatformLog(); + + assertEquals(afterLiveWarning, emitted.size()); + } + + @Test + public void anEmptyWorldFolderIsReportedWithItsOwnRemedy() { + bindCapturingPlatform(); + + MissingWorldStorageLog.warnEmptyOnce("world_iris_husk", Path.of("/srv/world/dimensions/iris/husk")); + + assertTrue(MissingWorldStorageLog.hasWarned("world_iris_husk")); + assertEquals(2, emitted.size()); + assertTrue(emitted.get(0), emitted.get(0).contains("/srv/world/dimensions/iris/husk")); + assertTrue(emitted.get(1), emitted.get(1).contains("world_iris_husk")); + assertFalse("an empty folder is deleted, not restored", emitted.get(1).contains("Restore")); + } + + /** + * An orphan report is what an operator scans logs/latest.log at WARN level for; emitting it at INFO makes + * it invisible to exactly the search that would find it. + */ + @Test + public void everyOrphanReportIsRaisedAtWarnLevel() { + bindCapturingPlatform(); + + MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone")); + MissingWorldStorageLog.warnEmptyOnce("world_iris_husk", Path.of("/srv/world/dimensions/iris/husk")); + + assertEquals(4, levels.size()); + assertTrue(levels.toString(), levels.stream().allMatch(level -> level == LogLevel.WARN)); + } + + @Test + public void replayedOrphanReportsKeepTheirWarnLevel() { + MissingWorldStorageLog.warnOnce("world_iris_gone", Path.of("/srv/world/dimensions/iris/gone")); + bindCapturingPlatform(); + + MissingWorldStorageLog.replayToPlatformLog(); + + assertEquals(2, levels.size()); + assertTrue(levels.toString(), levels.stream().allMatch(level -> level == LogLevel.WARN)); + } + + private void bindCapturingPlatform() { + IrisPlatform platform = mock(IrisPlatform.class); + doAnswer(invocation -> { + levels.add(invocation.getArgument(0, LogLevel.class)); + emitted.add(invocation.getArgument(1, String.class)); + return null; + }).when(platform).log(org.mockito.ArgumentMatchers.any(LogLevel.class), org.mockito.ArgumentMatchers.anyString()); + IrisPlatforms.bind(platform); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java index eb8eb45eb..91fe49562 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java @@ -116,4 +116,34 @@ public class WorldRemovalPathPolicyTest { ); assertEquals(WorldRemovalPathPolicy.RejectionReason.SYMBOLIC_LINK, targetFailure.reason()); } + + /** + * "iris worlds", Multiverse and the storage folder each print a different name for the same world, so + * all three forms have to select it. Storage presence must not change which world is selected either; + * an orphan is exactly the world an admin needs to remove. + */ + @Test + public void everyPrintedFormOfAWorldNameSelectsTheSameTarget() throws Exception { + Path levelRoot = temporaryFolder.newFolder("name-forms", "world").toPath(); + + for (boolean storagePresent : new boolean[]{false, true}) { + if (storagePresent) { + Files.createDirectories(levelRoot.resolve("dimensions/iris/orphan1")); + } + WorldRemovalPathPolicy.Target bare = + WorldRemovalPathPolicy.resolve("orphan1", "world", levelRoot); + WorldRemovalPathPolicy.Target startup = + WorldRemovalPathPolicy.resolve("world_iris_orphan1", "world", levelRoot); + WorldRemovalPathPolicy.Target namespaced = + WorldRemovalPathPolicy.resolve("iris:orphan1", "world", levelRoot); + + assertEquals("iris:orphan1", bare.worldKey().toString()); + assertEquals(bare.worldKey(), startup.worldKey()); + assertEquals(bare.worldKey(), namespaced.worldKey()); + assertEquals(bare.worldDirectory(), startup.worldDirectory()); + assertEquals(bare.worldDirectory(), namespaced.worldDirectory()); + assertEquals(bare.storageDirectory(), startup.storageDirectory()); + assertEquals(bare.storageDirectory(), namespaced.storageDirectory()); + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/link/MultiverseCoreLinkTest.java b/core/src/test/java/art/arcane/iris/core/link/MultiverseCoreLinkTest.java index 499630fac..d0dedbe62 100644 --- a/core/src/test/java/art/arcane/iris/core/link/MultiverseCoreLinkTest.java +++ b/core/src/test/java/art/arcane/iris/core/link/MultiverseCoreLinkTest.java @@ -1,19 +1,46 @@ package art.arcane.iris.core.link; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; import org.bukkit.World; +import org.bukkit.plugin.PluginManager; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; +import org.mvplugins.multiverse.core.utils.result.Attempt; +import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld; +import org.mvplugins.multiverse.core.world.MultiverseWorld; +import org.mvplugins.multiverse.core.world.WorldManager; +import org.mvplugins.multiverse.core.world.options.LoadWorldOptions; +import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions; +import org.mvplugins.multiverse.external.vavr.control.Option; +import java.io.File; import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.List; +import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class MultiverseCoreLinkTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test public void multiverseRegistrationRequiresTheConfiguredWorldName() throws Exception { Method updateWorld = MultiverseCoreLink.class @@ -37,17 +64,179 @@ public class MultiverseCoreLinkTest { } @Test - public void lookupNamesAddTheIrisKeyForConfiguredStartupNames() { + public void lookupNamesAddTheIrisKeyAndRuntimeNameForConfiguredStartupNames() { assertEquals( - List.of("world_iris_mvtest", "iris:mvtest"), + List.of("world_iris_mvtest", "iris:mvtest", "iris_mvtest"), MultiverseCoreLink.lookupNames("world_iris_mvtest", "world") ); assertEquals( - List.of("survival_iris_mvtest", "iris:mvtest"), + List.of("survival_iris_mvtest", "iris:mvtest", "iris_mvtest"), MultiverseCoreLink.lookupNames("survival_iris_mvtest", "survival") ); } + /** + * Multiverse re-creates a world under whatever name its own WorldCreator produced, so an entry it + * reloaded can be indexed under the keyed runtime name instead of the startup name Iris registered. + */ + @Test + public void resolveFindsAWorldMultiverseOnlyHoldsUnderItsRuntimeName() { + MultiverseWorld target = multiverseWorld("iris_mvtest", new NamespacedKey("iris", "mvtest")); + + assertSame(target, new MultiverseCoreLink() + .resolve(worldManagerHolding(Map.of("iris_mvtest", target)), "world_iris_mvtest", "world")); + } + + @Test + public void resolveNeverReturnsADifferentWorldSittingOnTheRuntimeName() { + MultiverseWorld impostor = multiverseWorld("iris_mvtest", NamespacedKey.minecraft("iris_mvtest")); + + assertNull(new MultiverseCoreLink() + .resolve(worldManagerHolding(Map.of("iris_mvtest", impostor)), "world_iris_mvtest", "world")); + } + + @Test + public void resolveStillMatchesALegacyNameKeyedEntryUnderTheConfiguredName() { + MultiverseWorld legacy = multiverseWorld("world_iris_mvtest", NamespacedKey.minecraft("world_iris_mvtest")); + + assertSame(legacy, new MultiverseCoreLink() + .resolve(worldManagerHolding(Map.of("world_iris_mvtest", legacy)), "world_iris_mvtest", "world")); + } + + /** + * Iris unloads the world and closes its generator before it unregisters it, so Multiverse must not run + * its own unload-before-remove: that dereferences a Bukkit world which is already gone and fails the + * whole removal. + */ + @Test + public void removalSkipsTheMultiverseUnloadWhenTheBukkitWorldIsAlreadyGone() { + MultiverseWorld unloaded = multiverseWorld("world_iris_mvtest", new NamespacedKey("iris", "mvtest")); + when(unloaded.asLoadedWorld()).thenReturn(Option.none()); + + RemoveWorldOptions options = MultiverseCoreLink.removalOptions(unloaded); + + assertFalse(options.unloadBukkitWorld()); + assertFalse(options.saveBukkitWorld()); + } + + @Test + public void removalStillLetsMultiverseUnloadAWorldThatIsStillLive() { + MultiverseWorld live = multiverseWorld("world_iris_mvtest", new NamespacedKey("iris", "mvtest")); + LoadedMultiverseWorld loaded = mock(LoadedMultiverseWorld.class); + when(loaded.getBukkitWorld()).thenReturn(Option.of(mock(World.class))); + when(live.asLoadedWorld()).thenReturn(Option.of(loaded)); + + assertTrue(MultiverseCoreLink.removalOptions(live).unloadBukkitWorld()); + } + + /** + * Multiverse's loadWorld short-circuits to the already-loaded Bukkit world only when Bukkit has one + * under the name Multiverse recorded. Without that world it builds one, so adoption is skipped rather + * than letting Multiverse re-create a world Iris already owns. + */ + @Test + public void adoptionIsSkippedWhenBukkitHasNoWorldUnderTheRecordedName() { + NamespacedKey worldKey = new NamespacedKey("iris", "mvtest"); + MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey); + WorldManager manager = mock(WorldManager.class); + when(manager.isLoadedWorld(target)).thenReturn(false); + + withBukkitWorld(null, () -> assertFalse( + new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey))); + verify(manager, never()).loadWorld(any(LoadWorldOptions.class)); + } + + @Test + public void adoptionIsSkippedWhenTheLiveWorldUnderThatNameIsNotTheTarget() { + NamespacedKey worldKey = new NamespacedKey("iris", "mvtest"); + MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey); + WorldManager manager = mock(WorldManager.class); + when(manager.isLoadedWorld(target)).thenReturn(false); + World other = mock(World.class); + when(other.getKey()).thenReturn(NamespacedKey.minecraft("overworld")); + + withBukkitWorld(other, () -> assertFalse( + new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey))); + verify(manager, never()).loadWorld(any(LoadWorldOptions.class)); + } + + @Test + public void adoptionIsSkippedWhenMultiverseAlreadyHoldsTheWorldAsLoaded() { + NamespacedKey worldKey = new NamespacedKey("iris", "mvtest"); + MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey); + WorldManager manager = mock(WorldManager.class); + when(manager.isLoadedWorld(target)).thenReturn(true); + + assertFalse(new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey)); + verify(manager, never()).loadWorld(any(LoadWorldOptions.class)); + } + + @Test + public void adoptionBindsALiveIrisWorldIntoTheMultiverseLoadedRegistry() { + NamespacedKey worldKey = new NamespacedKey("iris", "mvtest"); + MultiverseWorld target = multiverseWorld("world_iris_mvtest", worldKey); + WorldManager manager = mock(WorldManager.class); + when(manager.isLoadedWorld(target)).thenReturn(false); + when(manager.loadWorld(any(LoadWorldOptions.class))) + .thenReturn(Attempt.success(mock(LoadedMultiverseWorld.class))); + World live = mock(World.class); + when(live.getKey()).thenReturn(worldKey); + + withBukkitWorld(live, () -> assertTrue( + new MultiverseCoreLink().adoptLoadedWorld(manager, target, worldKey))); + } + + /** + * Multiverse refuses to adopt a world whose stored environment disagrees with the live one, so the live + * world is the value the entry is corrected to whenever there is one. + */ + @Test + public void theStoredEnvironmentIsTakenFromTheLiveWorld() { + NamespacedKey worldKey = new NamespacedKey("iris", "mvtest"); + World live = mock(World.class); + when(live.getKey()).thenReturn(worldKey); + when(live.getEnvironment()).thenReturn(World.Environment.NETHER); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getWorlds).thenReturn(List.of(live)); + + assertEquals(World.Environment.NETHER, + new MultiverseCoreLink().ownedWorldEnvironment(worldKey, "overworld")); + } + } + + @Test + public void anEnvironmentThatMatchesWhatMultiverseAlreadyStoredIsNotRewritten() { + MultiverseWorld multiverseWorld = multiverseWorld("world_iris_mvtest", new NamespacedKey("iris", "mvtest")); + when(multiverseWorld.getEnvironment()).thenReturn(World.Environment.NORMAL); + MultiverseCoreLink link = new MultiverseCoreLink(); + + assertFalse(link.applyIrisWorldEnvironment(multiverseWorld, World.Environment.NORMAL)); + assertFalse(link.applyIrisWorldEnvironment(multiverseWorld, null)); + } + + private static void withBukkitWorld(World world, Runnable body) { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getWorld(anyString())).thenReturn(world); + body.run(); + } + } + + private static MultiverseWorld multiverseWorld(String name, NamespacedKey key) { + MultiverseWorld multiverseWorld = mock(MultiverseWorld.class); + when(multiverseWorld.getName()).thenReturn(name); + when(multiverseWorld.getKey()).thenReturn(key); + when(multiverseWorld.asLoadedWorld()).thenReturn(Option.none()); + return multiverseWorld; + } + + private static WorldManager worldManagerHolding(Map registry) { + WorldManager manager = mock(WorldManager.class); + when(manager.getWorld(anyString())) + .thenAnswer(invocation -> Option.of(registry.get(invocation.getArgument(0)))); + return manager; + } + @Test public void lookupNamesKeepNonConfiguredNamesUntouched() { assertEquals(List.of("iris_mvtest"), MultiverseCoreLink.lookupNames("iris_mvtest", "world")); @@ -55,4 +244,73 @@ public class MultiverseCoreLinkTest { assertEquals(List.of("iris:mvtest"), MultiverseCoreLink.lookupNames("iris:mvtest", "world")); assertEquals(List.of("iris_studio-demo"), MultiverseCoreLink.lookupNames("iris_studio-demo", "world")); } + + @Test + public void everyLinkCallIsANoOpWithoutMultiverse() { + withMultiverse(false, link -> { + World world = mock(World.class); + when(world.getName()).thenReturn("world_iris_mvtest"); + + assertFalse(link.isActive()); + assertFalse(link.removeIfPresent(world)); + assertFalse(link.removeFromConfig("world_iris_mvtest")); + assertEquals(0, link.reconcileOwnedWorlds()); + link.updateWorld(world, "world_iris_mvtest", "overworld"); + link.prepareOwnedWorldLoad("world_iris_mvtest"); + assertFalse(link.adoptOwnedWorld("world_iris_mvtest")); + }); + } + + @Test + public void anEnabledButUnloadedMultiverseDegradesInsteadOfThrowing() { + // Iris is asked for generators while Multiverse is still enabling, so plugin enablement alone + // must never be treated as a usable API. + withMultiverse(true, link -> { + World world = mock(World.class); + when(world.getName()).thenReturn("world_iris_mvtest"); + + assertFalse("MultiverseCoreApi has no instance until Multiverse finishes enabling", + link.isActive()); + assertFalse(link.removeIfPresent(world)); + assertFalse(link.removeFromConfig("world_iris_mvtest")); + assertEquals(0, link.reconcileOwnedWorlds()); + link.updateWorld(world, "world_iris_mvtest", "overworld"); + link.prepareOwnedWorldLoad("world_iris_mvtest"); + assertFalse(link.adoptOwnedWorld("world_iris_mvtest")); + }); + } + + @Test + public void aBlankWorldNameIsNotAnErrorWhileMultiverseIsAbsent() { + withMultiverse(false, link -> { + assertFalse(link.removeFromConfig(" ")); + assertFalse(link.removeFromConfig(null)); + link.prepareOwnedWorldLoad(null); + assertFalse(link.adoptOwnedWorld(" ")); + }); + } + + @Test + public void irisOwnershipOfAWorldNameFollowsItsStorage() throws Exception { + File levelRoot = temporaryFolder.newFolder("ownership", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + + assertTrue(MultiverseCoreLink.isIrisOwnedWorldName("world_iris_mvtest", levelRoot)); + assertTrue(MultiverseCoreLink.isIrisOwnedWorldName("mvtest", levelRoot)); + assertTrue(MultiverseCoreLink.isIrisOwnedWorldName("iris:mvtest", levelRoot)); + assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("world_iris_other", levelRoot)); + assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("world", levelRoot)); + assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("../escape", levelRoot)); + assertFalse(MultiverseCoreLink.isIrisOwnedWorldName(null, levelRoot)); + assertFalse(MultiverseCoreLink.isIrisOwnedWorldName("mvtest", null)); + } + + private static void withMultiverse(boolean enabled, java.util.function.Consumer body) { + PluginManager pluginManager = mock(PluginManager.class); + when(pluginManager.isPluginEnabled(MultiverseCoreLink.MULTIVERSE_PLUGIN)).thenReturn(enabled); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + body.accept(new MultiverseCoreLink()); + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/link/MultiverseGuardListenerTest.java b/core/src/test/java/art/arcane/iris/core/link/MultiverseGuardListenerTest.java new file mode 100644 index 000000000..f6a235721 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/link/MultiverseGuardListenerTest.java @@ -0,0 +1,406 @@ +package art.arcane.iris.core.link; + +import art.arcane.iris.core.lifecycle.ManagedWorldLoader; +import art.arcane.iris.engine.platform.BukkitChunkGenerator; +import art.arcane.iris.spi.IrisServices; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.event.server.ServerCommandEvent; +import org.bukkit.plugin.PluginManager; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; +import org.mvplugins.multiverse.core.event.world.MVWorldDeleteEvent; +import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld; +import org.mvplugins.multiverse.external.vavr.control.Option; + +import java.io.File; +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class MultiverseGuardListenerTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void multiverseCommandRootsAreRecognisedThroughEveryDispatchForm() { + assertEquals("mv", MultiverseGuardListener.multiverseCommandRoot("/mv clone a b")); + assertEquals("mv", MultiverseGuardListener.multiverseCommandRoot("mv clone a b")); + assertEquals("mv", MultiverseGuardListener.multiverseCommandRoot("/multiverse-core:mv delete a")); + assertEquals("mvclone", MultiverseGuardListener.multiverseCommandRoot("/MVClone a b")); + assertEquals("mvregen", MultiverseGuardListener.multiverseCommandRoot("/mvregen a")); + assertNull(MultiverseGuardListener.multiverseCommandRoot("/iris remove world=a")); + assertNull(MultiverseGuardListener.multiverseCommandRoot("/tp somebody")); + assertNull(MultiverseGuardListener.multiverseCommandRoot(" ")); + assertNull(MultiverseGuardListener.multiverseCommandRoot(null)); + } + + @Test + public void cloneSourceAndDestinationAreReadFromEveryCloneAlias() { + assertClone("mvtest", "copy", "/mv clone mvtest copy"); + assertClone("mvtest", "copy", "mv clone mvtest copy --reset-gamerules"); + assertClone("mvtest", "copy", "/mvcl mvtest copy"); + assertClone("mvtest", "copy", "/mvclone mvtest copy"); + assertClone("mvtest", "copy", "/mv clone mvtest copy"); + assertClone("mvtest", null, "/mvcl mvtest"); + assertClone("mvtest", null, "/mvcl mvtest --reset-gamerules"); + } + + @Test + public void loadTargetsAreReadFromEveryLoadAlias() { + assertEquals("world_iris_mvtest", MultiverseGuardListener.multiverseLoad("/mv load world_iris_mvtest")); + assertEquals("world_iris_mvtest", MultiverseGuardListener.multiverseLoad("mv LOAD world_iris_mvtest")); + assertEquals("world_iris_mvtest", + MultiverseGuardListener.multiverseLoad("/mvload world_iris_mvtest --skip-folder-check")); + assertEquals("world_iris_mvtest", + MultiverseGuardListener.multiverseLoad("/mv load world_iris_mvtest")); + assertNull(MultiverseGuardListener.multiverseLoad("/mv load")); + assertNull(MultiverseGuardListener.multiverseLoad("/mvload")); + assertNull(MultiverseGuardListener.multiverseLoad("/mv unload world_iris_mvtest")); + assertNull(MultiverseGuardListener.multiverseLoad("/mv clone a b")); + assertNull(MultiverseGuardListener.multiverseLoad("/iris load world_iris_mvtest")); + } + + /** + * Multiverse rebuilds an unloaded world from a WorldCreator carrying the environment it stored, and an + * Iris world reports CUSTOM after its first restart, which CraftServer refuses outright. Iris owns the + * load, so the command is taken over rather than left to fail. + */ + @Test + public void loadingAnUnloadedIrisWorldIsDelegatedToIris() throws Exception { + File levelRoot = temporaryFolder.newFolder("load-delegate", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + RecordingLoader loader = new RecordingLoader(true, "Loaded world_iris_mvtest."); + IrisServices.register(ManagedWorldLoader.class, loader); + try { + withServer(levelRoot, null, listener -> { + ServerCommandEvent event = consoleCommand("mv load world_iris_mvtest"); + listener.onServerCommand(event); + assertTrue("Multiverse must never reach CraftServer.createWorld for an Iris world", + event.isCancelled()); + }); + assertEquals("world_iris_mvtest", loader.requested); + } finally { + IrisServices.remove(ManagedWorldLoader.class); + } + } + + /** + * Multiverse takes the bare key or iris: just as happily as the startup name; Iris has to load the + * one name the world is registered under either way. + */ + @Test + public void anAliasFormOfAnIrisWorldIsLoadedUnderItsStartupName() throws Exception { + File levelRoot = temporaryFolder.newFolder("load-alias", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + RecordingLoader loader = new RecordingLoader(true, "Loaded."); + IrisServices.register(ManagedWorldLoader.class, loader); + try { + withServer(levelRoot, null, listener -> { + ServerCommandEvent event = consoleCommand("mv load iris:mvtest"); + listener.onServerCommand(event); + assertTrue(event.isCancelled()); + }); + assertEquals("world_iris_mvtest", loader.requested); + } finally { + IrisServices.remove(ManagedWorldLoader.class); + } + } + + @Test + public void loadingAnIrisWorldThatIsAlreadyLiveIsLeftToMultiverse() throws Exception { + File levelRoot = temporaryFolder.newFolder("load-live", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + RecordingLoader loader = new RecordingLoader(true, "Loaded."); + IrisServices.register(ManagedWorldLoader.class, loader); + try { + withServer(levelRoot, irisWorld("world_iris_mvtest"), listener -> { + ServerCommandEvent event = consoleCommand("mv load world_iris_mvtest"); + listener.onServerCommand(event); + assertFalse("Multiverse adopts a live world without a WorldCreator", event.isCancelled()); + }); + assertNull(loader.requested); + } finally { + IrisServices.remove(ManagedWorldLoader.class); + } + } + + @Test + public void loadingAWorldIrisDoesNotOwnIsLeftAlone() throws Exception { + File levelRoot = temporaryFolder.newFolder("load-vanilla", "world"); + RecordingLoader loader = new RecordingLoader(true, "Loaded."); + IrisServices.register(ManagedWorldLoader.class, loader); + try { + withServer(levelRoot, null, listener -> { + ServerCommandEvent event = consoleCommand("mv load plots"); + listener.onServerCommand(event); + assertFalse(event.isCancelled()); + }); + assertNull(loader.requested); + } finally { + IrisServices.remove(ManagedWorldLoader.class); + } + } + + /** + * Without a loader the command is still refused: letting Multiverse through would either fail on the + * CUSTOM environment or build a vanilla world beside the level root under the Iris world's name. + */ + @Test + public void loadingAnIrisWorldWithNoLoaderRegisteredIsRefusedRatherThanPassedThrough() throws Exception { + File levelRoot = temporaryFolder.newFolder("load-no-loader", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + IrisServices.remove(ManagedWorldLoader.class); + + withServer(levelRoot, null, listener -> { + ServerCommandEvent event = consoleCommand("mv load world_iris_mvtest"); + listener.onServerCommand(event); + assertTrue(event.isCancelled()); + }); + } + + /** + * Taking the command over must never do for a sender what Multiverse would have refused them. + */ + @Test + public void loadingAnIrisWorldWithoutTheMultiversePermissionIsLeftToMultiverse() throws Exception { + File levelRoot = temporaryFolder.newFolder("load-unprivileged", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + RecordingLoader loader = new RecordingLoader(true, "Loaded."); + IrisServices.register(ManagedWorldLoader.class, loader); + try { + withServer(levelRoot, null, listener -> { + ServerCommandEvent event = unprivilegedCommand("mv load world_iris_mvtest"); + listener.onServerCommand(event); + assertFalse(event.isCancelled()); + }); + assertNull(loader.requested); + } finally { + IrisServices.remove(ManagedWorldLoader.class); + } + } + + /** + * The tail of a delegated load runs on the main thread and hands the world to Multiverse. Without + * Multiverse it must still settle, in both directions, rather than throw into the scheduler. + */ + @Test + public void aSettledLoadHandsTheWorldToMultiverseAndNeverThrows() throws Exception { + File levelRoot = temporaryFolder.newFolder("load-settle", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + + withServer(levelRoot, null, listener -> { + listener.settleLoad(null, "world_iris_mvtest", + new ManagedWorldLoader.ManagedWorldLoad(true, "Loaded."), null); + listener.settleLoad(null, "world_iris_mvtest", + new ManagedWorldLoader.ManagedWorldLoad(false, "Storage is missing."), null); + listener.settleLoad(null, "world_iris_mvtest", null, new IllegalStateException("boom")); + listener.settleLoad(null, "world_iris_mvtest", null, null); + }); + } + + private static final class RecordingLoader implements ManagedWorldLoader { + private final boolean loaded; + private final String message; + private volatile String requested; + + private RecordingLoader(boolean loaded, String message) { + this.loaded = loaded; + this.message = message; + } + + @Override + public CompletableFuture load(String configuredWorldName) { + requested = configuredWorldName; + return CompletableFuture.completedFuture(new ManagedWorldLoad(loaded, message)); + } + } + + @Test + public void nonCloneMultiverseCommandsCarryNoCloneSource() { + assertNull(MultiverseGuardListener.multiverseClone("/mv delete mvtest")); + assertNull(MultiverseGuardListener.multiverseClone("/mv regen mvtest")); + assertNull(MultiverseGuardListener.multiverseClone("/mv clone")); + assertNull(MultiverseGuardListener.multiverseClone("/mv")); + assertNull(MultiverseGuardListener.multiverseClone("/mvcl")); + assertNull(MultiverseGuardListener.multiverseClone("/iris create mvtest")); + } + + @Test + public void cloningOntoAnIrisWorldSlotIsRefused() throws Exception { + File levelRoot = temporaryFolder.newFolder("clone-destination", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + + withServer(levelRoot, null, listener -> { + ServerCommandEvent event = consoleCommand("mv clone plots world_iris_mvtest"); + listener.onServerCommand(event); + assertTrue("Multiverse resolves the vanilla path and never sees the Iris slot", + event.isCancelled()); + }); + } + + @Test + public void cloningOntoAFreeNameIsLeftAlone() throws Exception { + File levelRoot = temporaryFolder.newFolder("clone-destination-free", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + + withServer(levelRoot, null, listener -> { + ServerCommandEvent event = consoleCommand("mv clone plots plots_copy"); + listener.onServerCommand(event); + assertFalse(event.isCancelled()); + }); + } + + private static void assertClone(String source, String destination, String commandLine) { + MultiverseGuardListener.CloneCommand clone = MultiverseGuardListener.multiverseClone(commandLine); + assertEquals(commandLine, new MultiverseGuardListener.CloneCommand(source, destination), clone); + } + + @Test + public void multiverseDeleteOfALoadedIrisWorldIsCancelled() { + World world = irisWorld("world_iris_mvtest"); + LoadedMultiverseWorld multiverseWorld = multiverseWorld("world_iris_mvtest", world); + MVWorldDeleteEvent event = new MVWorldDeleteEvent(multiverseWorld); + + withServer(emptyLevelRoot("delete-iris"), listener -> listener.onMultiverseWorldDelete(event)); + + assertTrue("mv delete and mv regen both funnel through this event", event.isCancelled()); + } + + @Test + public void multiverseDeleteOfAWorldIrisDoesNotOwnIsLeftAlone() { + World world = mock(World.class); + when(world.getName()).thenReturn("plots"); + LoadedMultiverseWorld multiverseWorld = multiverseWorld("plots", world); + MVWorldDeleteEvent event = new MVWorldDeleteEvent(multiverseWorld); + + withServer(emptyLevelRoot("delete-vanilla"), listener -> listener.onMultiverseWorldDelete(event)); + + assertFalse(event.isCancelled()); + } + + @Test + public void multiverseDeleteOfAnUnloadedIrisWorldIsCancelledFromStorage() throws Exception { + File levelRoot = temporaryFolder.newFolder("delete-unloaded", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + LoadedMultiverseWorld multiverseWorld = multiverseWorld("world_iris_mvtest", null); + MVWorldDeleteEvent event = new MVWorldDeleteEvent(multiverseWorld); + + withServer(levelRoot, listener -> listener.onMultiverseWorldDelete(event)); + + assertTrue(event.isCancelled()); + } + + @Test + public void cloningALoadedIrisWorldIsRefusedAtTheCommand() { + World world = irisWorld("world_iris_mvtest"); + + withServer(emptyLevelRoot("clone-iris"), world, listener -> { + ServerCommandEvent event = consoleCommand("mv clone world_iris_mvtest copy"); + listener.onServerCommand(event); + assertTrue("Multiverse fires no cancellable event for a clone", event.isCancelled()); + }); + } + + @Test + public void cloningAnUnloadedIrisWorldIsRefusedFromStorage() throws Exception { + File levelRoot = temporaryFolder.newFolder("clone-unloaded", "world"); + assertTrue(new File(levelRoot, "dimensions/iris/mvtest").mkdirs()); + + withServer(levelRoot, null, listener -> { + ServerCommandEvent event = consoleCommand("mvclone world_iris_mvtest copy"); + listener.onServerCommand(event); + assertTrue(event.isCancelled()); + }); + } + + @Test + public void cloningAWorldIrisDoesNotOwnIsLeftAlone() { + withServer(emptyLevelRoot("clone-vanilla"), null, listener -> { + ServerCommandEvent event = consoleCommand("mv clone plots copy"); + listener.onServerCommand(event); + assertFalse(event.isCancelled()); + }); + } + + @Test + public void unrelatedCommandsAreNeverInspected() { + withServer(emptyLevelRoot("clone-unrelated"), null, listener -> { + ServerCommandEvent event = consoleCommand("iris remove world=world_iris_mvtest delete=true"); + listener.onServerCommand(event); + assertFalse(event.isCancelled()); + }); + } + + private static ServerCommandEvent consoleCommand(String command) { + ConsoleCommandSender sender = mock(ConsoleCommandSender.class); + when(sender.hasPermission(org.mockito.ArgumentMatchers.anyString())).thenReturn(true); + return new ServerCommandEvent(sender, command); + } + + private static ServerCommandEvent unprivilegedCommand(String command) { + return new ServerCommandEvent(mock(ConsoleCommandSender.class), command); + } + + private File emptyLevelRoot(String scope) { + try { + return temporaryFolder.newFolder(scope, "world"); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + } + + private static World irisWorld(String name) { + World world = mock(World.class); + when(world.getName()).thenReturn(name); + when(world.getGenerator()).thenReturn(mock(BukkitChunkGenerator.class)); + return world; + } + + private static LoadedMultiverseWorld multiverseWorld(String name, World bukkitWorld) { + LoadedMultiverseWorld multiverseWorld = mock(LoadedMultiverseWorld.class); + when(multiverseWorld.getName()).thenReturn(name); + when(multiverseWorld.asLoadedWorld()).thenReturn(Option.of(multiverseWorld)); + when(multiverseWorld.getBukkitWorld()).thenReturn(Option.of(bukkitWorld)); + return multiverseWorld; + } + + private static void withServer(File levelRoot, java.util.function.Consumer body) { + withServer(levelRoot, null, body); + } + + /** + * Multiverse is left disabled so the listener resolves everything from Bukkit and Iris storage; that is + * the shape the guard has to hold in whether or not the Multiverse API answers. + */ + private static void withServer( + File levelRoot, + World namedWorld, + java.util.function.Consumer body + ) { + Server server = mock(Server.class); + when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); + PluginManager pluginManager = mock(PluginManager.class); + when(pluginManager.isPluginEnabled(MultiverseCoreLink.MULTIVERSE_PLUGIN)).thenReturn(false); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getWorldContainer).thenReturn(levelRoot.getParentFile()); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + if (namedWorld != null) { + bukkit.when(() -> Bukkit.getWorld(namedWorld.getName())).thenReturn(namedWorld); + } + body.accept(new MultiverseGuardListener(new MultiverseCoreLink())); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/MultiverseSVCTest.java b/core/src/test/java/art/arcane/iris/core/service/MultiverseSVCTest.java new file mode 100644 index 000000000..d580dedaf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/MultiverseSVCTest.java @@ -0,0 +1,116 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.link.MultiverseCoreLink; +import art.arcane.iris.core.link.MultiverseGuardListener; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.plugin.VolmitPlugin; +import org.bukkit.Bukkit; +import org.bukkit.event.Listener; +import org.bukkit.event.server.PluginDisableEvent; +import org.bukkit.event.server.PluginEnableEvent; +import org.bukkit.plugin.Plugin; +import org.junit.After; +import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class MultiverseSVCTest { + @After + public void unbindLink() { + IrisServices.remove(MultiverseCoreLink.class); + } + + @Test + public void anAbsentMultiverseLeavesTheGuardUnregistered() { + MultiverseCoreLink link = bindLink(false); + VolmitPlugin plugin = mock(VolmitPlugin.class); + + run(plugin, service -> service.onEnable()); + + verify(plugin, never()).registerListener(any()); + verify(link, never()).reconcileOwnedWorlds(); + } + + @Test + public void anAlreadyEnabledMultiverseIsPickedUpAtServiceEnable() { + MultiverseCoreLink link = bindLink(true); + VolmitPlugin plugin = mock(VolmitPlugin.class); + + run(plugin, service -> service.onEnable()); + + verify(plugin).registerListener(isA(MultiverseGuardListener.class)); + verify(link).reconcileOwnedWorlds(); + } + + @Test + public void multiverseEnablingAfterIrisRegistersTheGuardExactlyOnce() { + MultiverseCoreLink link = bindLink(true); + VolmitPlugin plugin = mock(VolmitPlugin.class); + + run(plugin, service -> { + service.onEnable(); + service.onPluginEnable(new PluginEnableEvent(namedPlugin("Multiverse-Core"))); + }); + + verify(plugin, times(1)).registerListener(any(Listener.class)); + verify(link, times(1)).reconcileOwnedWorlds(); + } + + @Test + public void anUnrelatedPluginNeverTouchesTheMultiverseLink() { + MultiverseCoreLink link = bindLink(true); + VolmitPlugin plugin = mock(VolmitPlugin.class); + + run(plugin, service -> { + service.onPluginEnable(new PluginEnableEvent(namedPlugin("WorldEdit"))); + service.onPluginDisable(new PluginDisableEvent(namedPlugin("WorldEdit"))); + }); + + verify(plugin, never()).registerListener(any()); + verify(link, never()).reconcileOwnedWorlds(); + } + + @Test + public void multiverseDisablingTakesTheGuardBackDown() { + bindLink(true); + VolmitPlugin plugin = mock(VolmitPlugin.class); + + run(plugin, service -> { + service.onEnable(); + service.onPluginDisable(new PluginDisableEvent(namedPlugin("Multiverse-Core"))); + }); + + verify(plugin).unregisterListener(isA(MultiverseGuardListener.class)); + } + + private static MultiverseCoreLink bindLink(boolean active) { + MultiverseCoreLink link = mock(MultiverseCoreLink.class); + when(link.isActive()).thenReturn(active); + IrisServices.register(MultiverseCoreLink.class, link); + return link; + } + + private static Plugin namedPlugin(String name) { + Plugin plugin = mock(Plugin.class); + when(plugin.getName()).thenReturn(name); + return plugin; + } + + private static void run(VolmitPlugin plugin, java.util.function.Consumer body) { + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic platform = mockStatic(BukkitPlatform.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + platform.when(BukkitPlatform::volmitPlugin).thenReturn(plugin); + body.accept(new MultiverseSVC()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java index 32434cc07..744e6227e 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineDataPersistenceTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine; +import art.arcane.iris.core.lifecycle.VanishedWorldStorage; import art.arcane.iris.engine.object.IrisEngineData; import com.google.gson.Gson; import org.junit.Rule; @@ -12,14 +13,81 @@ import java.nio.file.Files; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; public class IrisEngineDataPersistenceTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void atomicWriteRefusesToRebuildADeletedWorldFolder() throws Exception { + File worldFolder = temporaryFolder.newFolder("deleted-world"); + File output = new File(worldFolder, "iris/engine-data/dimension.json"); + assertTrue(worldFolder.delete()); + + assertThrows(IOException.class, + () -> EngineDataStore.writeEngineDataAtomically(output, new IrisEngineData())); + + assertFalse("a save must never resurrect a world tree", worldFolder.exists()); + } + + @Test + public void vanishedWorldStorageIsDetectedAndReported() throws Exception { + VanishedWorldStorage.reset(); + File worldFolder = temporaryFolder.newFolder("vanishing-world"); + + assertFalse(VanishedWorldStorage.vanished(worldFolder)); + assertTrue(worldFolder.delete()); + assertTrue(VanishedWorldStorage.vanished(worldFolder)); + VanishedWorldStorage.reset(); + } + + /** + * A save-all after a hot delete writes the level's own data/*.dat files back, which recreates the world + * folder. That folder is not the world returning, and letting Iris resume writing into it rebuilds an + * iris/ directory that makes the next boot classify the husk as unusable on some runs and empty on + * others. + */ + @Test + public void aWorldFolderTheServerRecreatesNeverReEnablesPersistence() throws Exception { + VanishedWorldStorage.reset(); + File worldFolder = temporaryFolder.newFolder("recreated-world"); + + assertFalse(VanishedWorldStorage.vanished(worldFolder)); + assertTrue(worldFolder.delete()); + assertTrue(VanishedWorldStorage.vanished(worldFolder)); + assertTrue(worldFolder.mkdirs()); + + assertTrue("a deleted world stays deleted for this JVM", VanishedWorldStorage.vanished(worldFolder)); + VanishedWorldStorage.reset(); + } + + /** + * The world folder can be back before Iris ever looks at it, so the latch alone is not enough: a tree + * Iris has already written that is now missing is the same delete. + */ + @Test + public void anEstablishedIrisTreeThatIsGoneCountsAsVanishedUnderARecreatedFolder() throws Exception { + VanishedWorldStorage.reset(); + File worldFolder = temporaryFolder.newFolder("hot-deleted-world"); + File engineData = new File(worldFolder, "iris/engine-data"); + assertTrue(engineData.mkdirs()); + + assertFalse(VanishedWorldStorage.vanished(worldFolder, engineData)); + assertTrue(engineData.delete()); + assertTrue(new File(worldFolder, "iris").delete()); + + assertTrue(VanishedWorldStorage.vanished(worldFolder, engineData)); + assertTrue("the verdict holds without the established tree too", + VanishedWorldStorage.vanished(worldFolder)); + VanishedWorldStorage.reset(); + } + @Test public void atomicWriteCreatesAndReplacesEngineData() throws Exception { - File folder = temporaryFolder.newFolder("engine-data"); + File worldFolder = temporaryFolder.newFolder("live-world"); + File folder = new File(worldFolder, "iris/engine-data"); File output = new File(folder, "dimension.json"); IrisEngineData first = new IrisEngineData(); first.getStatistics().setVersion(10); diff --git a/core/src/test/java/art/arcane/iris/engine/data/cache/AtomicCacheTest.java b/core/src/test/java/art/arcane/iris/engine/data/cache/AtomicCacheTest.java new file mode 100644 index 000000000..50aa29006 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/data/cache/AtomicCacheTest.java @@ -0,0 +1,106 @@ +package art.arcane.iris.engine.data.cache; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public class AtomicCacheTest { + @Test + public void aquireOnceOrThrowCachesTheComputedValue() { + AtomicCache cache = new AtomicCache<>(); + AtomicInteger calls = new AtomicInteger(); + + assertEquals("value", cache.aquireOnceOrThrow(() -> { + calls.incrementAndGet(); + return "value"; + })); + assertEquals("value", cache.aquireOnceOrThrow(() -> { + calls.incrementAndGet(); + return "other"; + })); + assertEquals(1, calls.get()); + } + + /** + * The retrying variant is what a caller that can repair the cause between attempts needs, and the + * world generator relies on it, so memoization must stay confined to aquireOnceOrThrow. + */ + @Test + public void aquireOrThrowStillRetriesAFailedSupplier() { + AtomicCache cache = new AtomicCache<>(); + AtomicInteger calls = new AtomicInteger(); + + assertThrows(IllegalStateException.class, () -> cache.aquireOrThrow(() -> { + calls.incrementAndGet(); + throw new IllegalStateException("repairable"); + })); + assertEquals("repaired", cache.aquireOrThrow(() -> { + calls.incrementAndGet(); + return "repaired"; + })); + assertEquals(2, calls.get()); + } + + @Test + public void aquireOnceOrThrowMemoizesTheSupplierFailureInsteadOfRerunningIt() { + AtomicCache cache = new AtomicCache<>(); + AtomicInteger calls = new AtomicInteger(); + IllegalStateException cause = new IllegalStateException("Frozen Iris pack snapshot is missing"); + + IllegalStateException first = assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> { + calls.incrementAndGet(); + throw cause; + })); + IllegalStateException second = assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> { + calls.incrementAndGet(); + return "recovered"; + })); + + assertSame(cause, first); + assertSame(cause, second); + assertEquals("the failure is stated once and replayed, never recomputed", 1, calls.get()); + } + + @Test + public void aquireOnceOrThrowRejectsANullValueAndRemembersThat() { + AtomicCache cache = new AtomicCache<>(); + AtomicInteger calls = new AtomicInteger(); + + assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> { + calls.incrementAndGet(); + return null; + })); + assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> { + calls.incrementAndGet(); + return "recovered"; + })); + assertEquals(1, calls.get()); + } + + @Test + public void resetClearsAMemoizedFailure() { + AtomicCache cache = new AtomicCache<>(); + + assertThrows(IllegalStateException.class, () -> cache.aquireOnceOrThrow(() -> { + throw new IllegalStateException("transient"); + })); + cache.reset(); + + assertEquals("recovered", cache.aquireOnceOrThrow(() -> "recovered")); + } + + @Test + public void aquireStillSwallowsFailuresForOptionalValues() { + AtomicCache cache = new AtomicCache<>(); + + assertNull(cache.aquire(() -> { + throw new IllegalStateException("optional value"); + })); + assertEquals("later", cache.aquire(() -> "later")); + } +} diff --git a/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java b/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java index 993ced0aa..8810cded5 100644 --- a/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java +++ b/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java @@ -4,65 +4,115 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; +/** + * Severity is stated by the caller and honoured by every adapter, so the levels {@link IrisLogging} offers + * decide what an operator sees in a WARN-level scan. The once helpers exist for conditions that are worth + * exactly one warning and would otherwise repeat per block, per sample or per chunk. + */ public class IrisLoggingTest { + private final List levels = new ArrayList<>(); + private final List messages = new ArrayList<>(); + private IrisPlatform previousPlatform; + @Before - public void resetBinding() { + public void captureLog() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; IrisPlatforms.unbind(); + levels.clear(); + messages.clear(); + bindCapturingPlatform(); } @After - public void clearBinding() { + public void restorePlatform() { IrisPlatforms.unbind(); - } - - @Test - public void contextualReportPrintsFullStacktraceWithBoundPlatform() { - IrisPlatform platform = mock(IrisPlatform.class, CALLS_REAL_METHODS); - IrisPlatforms.bind(platform); - IllegalStateException failure = new IllegalStateException("outer", new IllegalArgumentException("inner")); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - PrintStream originalErr = System.err; - System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8)); - try { - IrisLogging.reportError("Runtime world creation failed.", failure); - } finally { - System.setErr(originalErr); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); } + } - verify(platform).log(LogLevel.ERROR, "Runtime world creation failed."); - verify(platform).reportError("Runtime world creation failed.", failure); - verify(platform).reportError(failure); - String text = output.toString(StandardCharsets.UTF_8); - assertTrue(text.contains("IllegalStateException")); - assertTrue(text.contains("IllegalArgumentException")); - assertTrue(text.contains("inner")); + /** + * A lifecycle line is not a warning, but it is what an operator reads logs/latest.log for. NOTICE is the + * level adapters route to their own logger at INFO instead of to the console sender. + */ + @Test + public void noticeCarriesItsOwnLevel() { + IrisLogging.notice("Engine init: %s", "world"); + + assertEquals(List.of(LogLevel.NOTICE), levels); + assertEquals("Engine init: world", messages.getFirst()); } @Test - public void contextualReportHonorsPlatformOverrideSuppression() { + public void warnOnceStatesTheConditionOnceAndDropsRepeatsToDebug() { + String key = uniqueKey(); + + assertTrue(IrisLogging.warnOnce(key, "Can't find block data for %s", "mod:block")); + assertFalse(IrisLogging.warnOnce(key, "Can't find block data for %s", "mod:block")); + assertFalse(IrisLogging.warnOnce(key, "Can't find block data for %s", "mod:block")); + + assertEquals(List.of(LogLevel.WARN, LogLevel.DEBUG, LogLevel.DEBUG), levels); + assertTrue(messages.toString(), messages.stream().allMatch(m -> m.equals("Can't find block data for mod:block"))); + } + + @Test + public void aDistinctKeyIsItsOwnWarning() { + assertTrue(IrisLogging.warnOnce(uniqueKey(), "first")); + assertTrue(IrisLogging.warnOnce(uniqueKey(), "second")); + + assertEquals(List.of(LogLevel.WARN, LogLevel.WARN), levels); + } + + @Test + public void errorOnceKeepsTheSameContract() { + String key = uniqueKey(); + + assertTrue(IrisLogging.errorOnce(key, "Atomic cache supplier failed")); + assertFalse(IrisLogging.errorOnce(key, "Atomic cache supplier failed")); + + assertEquals(List.of(LogLevel.ERROR, LogLevel.DEBUG), levels); + } + + /** + * An operator fixes the pack and reloads. Holding the key for the life of the JVM would hide whether the + * fix worked, so unbinding the platform - which is what a reload does - clears them. + */ + @Test + public void unbindingThePlatformClearsTheOnceKeys() { + String key = uniqueKey(); + assertTrue(IrisLogging.warnOnce(key, "Empty Block Data for %s", "plains")); + + IrisPlatforms.unbind(); + levels.clear(); + bindCapturingPlatform(); + + assertTrue(IrisLogging.warnOnce(key, "Empty Block Data for %s", "plains")); + assertEquals(List.of(LogLevel.WARN), levels); + } + + private void bindCapturingPlatform() { IrisPlatform platform = mock(IrisPlatform.class); + doAnswer(invocation -> { + levels.add(invocation.getArgument(0, LogLevel.class)); + messages.add(invocation.getArgument(1, String.class)); + return null; + }).when(platform).log(any(LogLevel.class), anyString()); IrisPlatforms.bind(platform); - IllegalStateException failure = new IllegalStateException("suppressed"); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - PrintStream originalErr = System.err; - System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8)); - try { - IrisLogging.reportError("Throttled failure.", failure); - } finally { - System.setErr(originalErr); - } + } - verify(platform).reportError("Throttled failure.", failure); - assertFalse(output.toString(StandardCharsets.UTF_8).contains("suppressed")); + private static String uniqueKey() { + return "iris-logging-test:" + UUID.randomUUID(); } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e51ecba29..5196c979c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,7 +48,7 @@ eco = "2026.27" # https://repo.auxilor.io/repository/maven-public/com/willfp/eco mythic = "5.12.1" mythic-crucible = "2.2.0" kgenerators = "7.3" # https://repo.codemc.io/repository/maven-public/me/kryniowesegryderiusz/kgenerators-core/maven-metadata.xml -multiverseCore = "5.7.2" +multiverseCore = "5.8.0" craftengine = "26.7.3" # https://github.com/Xiao-MoMi/craft-engine/releases # Fabric API (net.fabricmc.fabric-api) - each module is independently versioned diff --git a/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java b/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java index 54bab13d3..25f7fd817 100644 --- a/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java +++ b/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java @@ -19,6 +19,8 @@ package art.arcane.iris.spi; import java.util.IllegalFormatException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; /** @@ -33,6 +35,8 @@ import java.util.regex.Pattern; * Internal to Iris; not a published integration surface. */ public final class IrisLogging { + private static final int MAX_ONCE_KEYS = 512; + private static final Set ONCE_KEYS = ConcurrentHashMap.newKeySet(); private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]"); private static final Pattern MINI_MESSAGE_TAG = Pattern.compile("(?i)\\n]{0,96})?>|<#[0-9a-f]{6}>"); @@ -54,6 +58,14 @@ public final class IrisLogging { emit(LogLevel.DEBUG, message); } + /** + * Logs at {@link LogLevel#NOTICE}, applying {@link #format(String, Object...)} to the arguments. For the + * few lifecycle milestones per boot that have to survive into the server's own log file. + */ + public static void notice(String format, Object... args) { + emit(LogLevel.NOTICE, format(format, args)); + } + /** * Logs at {@link LogLevel#WARN}, applying {@link #format(String, Object...)} to the arguments. */ @@ -68,6 +80,25 @@ public final class IrisLogging { emit(LogLevel.ERROR, format(format, args)); } + /** + * Logs at {@link LogLevel#WARN} the first time {@code key} is seen and at {@link LogLevel#DEBUG} every + * time after, for a condition that is worth stating once but is reached per block, per sample or per + * chunk. Returns true when the warning was the one that got stated, so a caller can attach a stack trace + * or other one-off detail to it. + * + * @see #resetOnceKeys() + */ + public static boolean warnOnce(String key, String format, Object... args) { + return once(LogLevel.WARN, key, format, args); + } + + /** + * {@link #warnOnce(String, String, Object...)} at {@link LogLevel#ERROR}. + */ + public static boolean errorOnce(String key, String format, Object... args) { + return once(LogLevel.ERROR, key, format, args); + } + /** * Emits a player-facing message to the console, preserving colour markup when a platform is bound and * stripping it via {@link #clean(String)} when one is not. @@ -156,6 +187,38 @@ public final class IrisLogging { return MINI_MESSAGE_TAG.matcher(legacyStripped).replaceAll(""); } + /** + * Forgets every key {@link #warnOnce(String, String, Object...)} and {@link #errorOnce} have claimed, so + * the next occurrence is stated again. Called when the platform unbinds: an operator who fixes a pack and + * reloads has to be able to see whether the fix took. + */ + static void resetOnceKeys() { + ONCE_KEYS.clear(); + } + + private static boolean once(LogLevel level, String key, String format, Object... args) { + String message = format(format, args); + if (!claim(key)) { + emit(LogLevel.DEBUG, message); + return false; + } + + emit(level, message); + return true; + } + + /** + * The cap is what keeps a key space nobody bounded - a block name, a biome name, a mod id - from becoming + * a leak. Past it nothing new is claimed, which drops later conditions to debug rather than growing. + */ + private static boolean claim(String key) { + if (ONCE_KEYS.size() >= MAX_ONCE_KEYS) { + return false; + } + + return ONCE_KEYS.add(key == null ? "null" : key); + } + private static void emit(LogLevel level, String message) { LogLevel target = level == null ? LogLevel.INFO : level; if (IrisPlatforms.isBound()) { diff --git a/spi/src/main/java/art/arcane/iris/spi/IrisPlatforms.java b/spi/src/main/java/art/arcane/iris/spi/IrisPlatforms.java index 2f8fb56db..2c5936200 100644 --- a/spi/src/main/java/art/arcane/iris/spi/IrisPlatforms.java +++ b/spi/src/main/java/art/arcane/iris/spi/IrisPlatforms.java @@ -47,9 +47,14 @@ public final class IrisPlatforms { /** * Clears the binding. Safe to call when nothing is bound. + *

+ * Also forgets the keys {@link IrisLogging#warnOnce(String, String, Object...)} has claimed. Unbinding is + * what a reload does, and an operator who has just fixed a pack has to see whether the condition that was + * stated once is still there. */ public static synchronized void unbind() { platform = null; + IrisLogging.resetOnceKeys(); } /** diff --git a/spi/src/main/java/art/arcane/iris/spi/LogLevel.java b/spi/src/main/java/art/arcane/iris/spi/LogLevel.java index 460576b37..387d098b4 100644 --- a/spi/src/main/java/art/arcane/iris/spi/LogLevel.java +++ b/spi/src/main/java/art/arcane/iris/spi/LogLevel.java @@ -33,6 +33,12 @@ public enum LogLevel { DEBUG, /** Normal operational messages. */ INFO, + /** + * Lifecycle milestones an operator reads the server log to find. Not a problem, but adapters route it to + * the host logger rather than to a console sender, so it survives into the log file the server writes. + * Reserved for a handful of events per boot. + */ + NOTICE, /** Recoverable problems and misconfiguration. */ WARN, /** Failures; usually paired with {@link IrisPlatform#reportError(Throwable)}. */