From eefcbc40efa7dc49e3177608ab6989c5498e0c12 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 06:30:28 -0400 Subject: [PATCH] Fail-closed review fixes - Modded logins are refused only when this save's dimension registry lists the primary world - Otherwise Iris warns once and leaves routing idle - An unreadable dimension registry counts as expecting the primary world - A vanilla slot bound to Iris without Iris storage points at the bukkit.yml entry and /iris replace - The refusal consequence comes from whether startup worlds exist instead of the first tick - The first WorldLoadEvent or a hotload marks startup worlds as existing - A failed enable on a hotload says the server keeps running - Reconciler resolution failures report the refusal banner - Operators get a join notice for such failures until the world loads - The fail-closed generator is built even when the cause message cannot be read - Tests drive Iris.onEnable for the lock, the unsupported-server lock and the rethrow - The failed-enable teardown decision lives in FailedEnableLock with a test --- .../art/arcane/iris/FailedEnableLock.java | 25 ++- .../src/main/java/art/arcane/iris/Iris.java | 21 ++- .../iris/world/BukkitWorldReconciler.java | 25 ++- .../world/IrisFailClosedChunkGenerator.java | 13 +- .../world/IrisWorldGeneratorResolver.java | 49 +++--- .../iris/world/WorldRefusalReporter.java | 122 +++++++++++++++ .../art/arcane/iris/FailedEnableLockTest.java | 133 +++++++++++++++- .../BukkitWorldReconcilerRuntimeTest.java | 38 ++++- .../IrisFailClosedChunkGeneratorTest.java | 21 +++ .../world/IrisWorldGeneratorResolverTest.java | 31 +--- .../iris/world/WorldRefusalReporterTest.java | 145 ++++++++++++++++++ .../iris/modded/ModdedPrimaryWorldRouter.java | 36 ++++- .../modded/ModdedPrimaryWorldRouterTest.java | 69 ++++++++- 13 files changed, 644 insertions(+), 84 deletions(-) create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java index 51488991a..cc332873f 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java @@ -26,12 +26,12 @@ final class FailedEnableLock { /** * @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before */ - static boolean engage(Throwable failure, boolean irisWorldsPresent) { + static boolean engage(Throwable failure, boolean irisWorldsPresent, boolean startupWorldsCreated) { if (!irisWorldsPresent) { return false; } IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure)); - List notice = notice(failure); + List notice = notice(failure, startupWorldsCreated); try { for (String line : notice) { Iris.error(line); @@ -45,14 +45,29 @@ final class FailedEnableLock { return true; } - static List notice(Throwable failure) { + /** + * Deferring teardown to the server stop quiesces through the NMS binding and waits for generators Paper closes. + * A failed enable may have neither, so it tears down at once even while the server stops. + */ + static boolean defersTeardownToServerStop(boolean enableFailed, boolean serverStopping) { + return !enableFailed && serverStopping; + } + + /** + * @param startupWorldsCreated true when Iris enabled on a running server (a hotload), whose startup worlds + * already exist, so nothing stops + */ + static List notice(Throwable failure, boolean startupWorldsCreated) { return GenerationRefusalNotice.compose( "Iris failed to enable", GenerationRefusalNotice.causes(failure), List.of( "Iris stays enabled with a locked runtime so no Iris world falls back to vanilla generation.", - "Every Iris world refuses to generate: server startup stops before the first one loads," - + " and no chunks are written.", + startupWorldsCreated + ? "Every Iris world that is not loaded yet refuses to load; the server keeps running" + + " and no chunks are written for them." + : "Every Iris world refuses to generate: server startup stops before the first one" + + " loads, and no chunks are written.", "Player logins are refused until this is fixed and the server restarts." )); } 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 eb3389337..25700400e 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 @@ -34,6 +34,7 @@ import art.arcane.iris.world.BukkitWorldReconciler; import art.arcane.iris.world.IrisWorldGeneratorResolver; import art.arcane.iris.world.PendingWorldDeleteQueue; import art.arcane.iris.world.PendingWorldReplacementManager; +import art.arcane.iris.world.WorldRefusalReporter; import art.arcane.iris.configuration.SettingsHotloadWatch; import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.datapack.DatapackIngestService; @@ -754,7 +755,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } J.s(() -> { - generatorResolver.markServerRunning(); pendingWorldReplacements.captureVanillaLevelContext(); pendingWorldReplacements.verifyLoadedPublishedWorlds(); J.a(this::bstats); @@ -900,6 +900,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisStartupValidation.begin(); Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); try { + generatorResolver.refusals().attach(this); IrisPlatforms.bind(new BukkitPlatform()); Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this); pendingWorldReplacements.registerPlatformEntryListener(); @@ -909,7 +910,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } enable(); } catch (Throwable failure) { - if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) { + if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(), + generatorResolver.refusals().startupWorldsCreated())) { throw failure; } enableFailed = true; @@ -933,7 +935,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null ? "no NMS binding is available for this server version." : bindFailure.getMessage()), bindFailure); - if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) { + if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(), + generatorResolver.refusals().startupWorldsCreated())) { enableFailed = true; return; } @@ -980,13 +983,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } IrisLanguage.shutdown(); teardownPapi(); - // A failed enable left no generator for Paper's shutdown to close, so it tears down the way a failed - // enable always did instead of deferring to the post-stop finisher. - boolean serverStopping = !enableFailed && IrisToolbelt.isServerStopping(); + boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping()); boolean restartingAtStartupBoundary = startupBoundaryRestart.get(); if (restartingAtStartupBoundary) { teardownRuntime("startup-boundary-restart", 30L); - } else if (serverStopping) { + } else if (deferToServerStop) { quiesceRuntimeForServerShutdown("onDisable"); startPostStopFinisher(); } else { @@ -1004,7 +1005,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } // super.onDisable() cancels plugin tasks and unregisters every listener. super.onDisable(); - if (!serverStopping || restartingAtStartupBoundary) { + if (!deferToServerStop || restartingAtStartupBoundary) { finishTerminalCleanup(); } } @@ -1429,6 +1430,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return generatorResolver.requireWorldGenerator(worldName, id); } + public WorldRefusalReporter worldRefusals() { + return generatorResolver.refusals(); + } + public void splash() { Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes()); printPacks(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java index 5d7fb0e72..dee36913c 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/BukkitWorldReconciler.java @@ -722,19 +722,30 @@ public final class BukkitWorldReconciler { return WorldIdentity.resolve(worldKey); } + /** + * A world Iris cannot resolve is refused like one CraftServer asked for, with the banner and operator notice, + * then fails this load only. + */ @Override public CompletableFuture createWorld(NamespacedKey worldKey, String dimension, Long seed) { + String configuredWorldName = worldKey.toString(); + ChunkGenerator generator; + IrisDimension irisDimension; try { - String logicalWorldName = IrisWorldStorage.logicalName(worldKey); - String configuredWorldName = configuredWorldName(worldKey); - Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension); - ChunkGenerator generator = plugin.requireWorldGenerator(configuredWorldName, dimension); - IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension); + configuredWorldName = configuredWorldName(worldKey); + Iris.info("Loading World: %s | Generator: %s", IrisWorldStorage.logicalName(worldKey), dimension); + generator = plugin.requireWorldGenerator(configuredWorldName, dimension); + irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension); if (generator == null || irisDimension == null) { throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\"."); } - - Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "..."); + } catch (Throwable refusal) { + plugin.worldRefusals().report(configuredWorldName, worldKey, refusal); + return CompletableFuture.failedFuture(refusal); + } + try { + Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + IrisWorldStorage.logicalName(worldKey) + + " using Iris:" + dimension + "..."); WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey) .generator(generator) .environment(BukkitEnvironment.from(irisDimension.getEnvironment())); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java index 020230bac..734ecb37a 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisFailClosedChunkGenerator.java @@ -49,9 +49,18 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator { + "' remains locked: " + denialReason); } + /** + * Built on the way back to CraftServer, which turns any throw into the vanilla generator, so a cause that cannot + * be summarized is named by its type instead. + */ static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) { - return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " - + GenerationRefusalNotice.summary(failure)); + String reason; + try { + reason = GenerationRefusalNotice.summary(failure); + } catch (Throwable unreadable) { + reason = failure.getClass().getName(); + } + return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + reason); } @Override diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java index 9529f5ec5..b4f4aeb3c 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java @@ -47,7 +47,6 @@ import art.arcane.iris.world.history.GenerationHistoryPaths; import art.arcane.iris.world.history.GenerationPackFingerprint; import art.arcane.iris.world.history.GenerationRegistryContract; import art.arcane.iris.world.history.GenerationRegistryContractFactory; -import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.iris.platform.generation.BukkitChunkGenerator; import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin; import art.arcane.iris.world.task.J; @@ -91,7 +90,7 @@ public final class IrisWorldGeneratorResolver { private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean(); private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean(); private final Map pendingSnapshots = new ConcurrentHashMap<>(); - private volatile boolean serverRunning; + private final WorldRefusalReporter refusals = new WorldRefusalReporter(); public IrisWorldGeneratorResolver(VolmitPlugin plugin) { this.plugin = plugin; @@ -533,48 +532,30 @@ public final class IrisWorldGeneratorResolver { return resolveFrozenWorldGenerator(worldName, dimension); } - /** - * Startup worlds are created before the first server tick; anything asked for after it is a runtime load. - */ - public void markServerRunning() { - serverRunning = true; + public WorldRefusalReporter refusals() { + return refusals; } private ChunkGenerator refuseWorld(String worldName, Throwable failure) { - ChunkGenerator refused = IrisFailClosedChunkGenerator.refused(worldName, failure); + refusals.report(worldName, refusalKey(worldName), failure); try { - for (String line : refusalNotice(worldName, refusalKey(worldName), failure, serverRunning)) { - Iris.error(line); - } Iris.reportError(failure); } catch (Throwable loggingFailure) { - failure.addSuppressed(loggingFailure); - failure.printStackTrace(System.err); + System.err.println("[Iris] Could not log the refusal of '" + worldName + "': " + + loggingFailure.getClass().getName()); } - return refused; + return IrisFailClosedChunkGenerator.refused(worldName, failure); } - private static String refusalKey(String worldName) { + @Nullable + private static NamespacedKey refusalKey(String worldName) { try { - return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName()).toString(); + return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName()); } catch (Throwable unresolved) { - return "unresolved key"; + return null; } } - static List refusalNotice(String worldName, String worldKey, Throwable failure, boolean serverRunning) { - return GenerationRefusalNotice.compose( - "Iris refused to generate world '" + worldName + "' (" + worldKey + ")", - GenerationRefusalNotice.causes(failure), - List.of( - "Iris does not generate this world and does not let vanilla or any other generator" - + " write it; no chunks are written.", - serverRunning - ? "The world does not load; the server keeps running. Fix the cause, then load it again." - : "Server startup stops before this world loads. Fix the cause, then start the server again." - )); - } - @Nullable private static ChunkGenerator startupLockedGenerator(String worldName) { Optional startupDenial = IrisStartupValidation.denialReason(); @@ -640,6 +621,14 @@ public final class IrisWorldGeneratorResolver { return; } NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName()); + if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) { + // Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run + // until that binding is gone. + throw new IllegalStateException("'" + worldName + "' (" + messageKey + + ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it." + + " Remove worlds." + worldName + ".generator from bukkit.yml, start the server, then run" + + " /iris replace " + messageKey + " type= to replace it with Iris on the next restart."); + } throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") has no Iris world storage, so Iris cannot generate it." + " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java new file mode 100644 index 000000000..97620537e --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/WorldRefusalReporter.java @@ -0,0 +1,122 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 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.world; + +import art.arcane.iris.Iris; +import art.arcane.iris.localization.C; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; +import art.arcane.volmlib.util.bukkit.WorldIdentity; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.world.WorldLoadEvent; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentSkipListMap; + +/** + * Reports Iris worlds that refused to generate: a console banner that says what the refusal does to the server, and + * an operator notice on join for as long as the world stays unloaded, since the banner scrolls away. + */ +public final class WorldRefusalReporter implements Listener { + private static final String UNRESOLVED_KEY = "unresolved key"; + + private final Map operatorNotices = new ConcurrentSkipListMap<>(); + private volatile boolean startupWorldsCreated; + + /** + * Iris enabled on a running server (a hotload) finds the startup worlds already loaded. + */ + public void attach(Plugin plugin) { + if (!Bukkit.getWorlds().isEmpty()) { + startupWorldsCreated = true; + } + Bukkit.getPluginManager().registerEvents(this, plugin); + } + + /** + * Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent. + * A generator asked for after that belongs to a runtime create, which fails only its caller. + */ + public boolean startupWorldsCreated() { + return startupWorldsCreated; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onWorldLoad(WorldLoadEvent event) { + startupWorldsCreated = true; + try { + operatorNotices.remove(WorldIdentity.key(event.getWorld()).toString()); + } catch (RuntimeException unkeyed) { + Iris.debug("Loaded world " + event.getWorld().getName() + " has no key to clear a refusal notice by"); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerJoin(PlayerJoinEvent event) { + if (operatorNotices.isEmpty()) { + return; + } + Player player = event.getPlayer(); + if (!player.isOp() && !player.hasPermission("iris.all")) { + return; + } + for (String notice : operatorNotices.values()) { + player.sendMessage(C.RED + notice); + } + } + + /** + * Never throws: the caller is handing CraftServer a fail-closed generator or failing a load, and a reporting + * failure must not turn either into something else. + */ + public void report(String worldName, @Nullable NamespacedKey worldKey, Throwable failure) { + String key = worldKey == null ? UNRESOLVED_KEY : worldKey.toString(); + try { + for (String line : notice(worldName, key, failure, startupWorldsCreated)) { + Iris.error(line); + } + operatorNotices.put(worldKey == null ? worldName : key, "Iris world '" + worldName + "' (" + key + + ") did not load and refuses to generate: " + GenerationRefusalNotice.summary(failure)); + } catch (Throwable reportingFailure) { + System.err.println("[Iris] Iris refused to generate world '" + worldName + "' (" + key + + "); the refusal could not be reported: " + reportingFailure.getClass().getName()); + } + } + + static List notice(String worldName, String worldKey, Throwable failure, boolean startupWorldsCreated) { + return GenerationRefusalNotice.compose( + "Iris refused to generate world '" + worldName + "' (" + worldKey + ")", + GenerationRefusalNotice.causes(failure), + List.of( + "Iris does not generate this world and does not let vanilla or any other generator" + + " write it; no chunks are written.", + startupWorldsCreated + ? "The world does not load; the server keeps running. Fix the cause, then load it again." + : "Server startup stops before this world loads. Fix the cause, then start the server again." + )); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java index c2ec0a824..15039e517 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java @@ -1,14 +1,20 @@ package art.arcane.iris; +import art.arcane.iris.platform.bukkit.nms.INMS; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.world.IrisStartupAdmissionListener; import art.arcane.iris.world.IrisStartupValidation; import art.arcane.iris.world.IrisWorldGeneratorResolver; +import art.arcane.iris.world.PendingWorldReplacementManager; +import art.arcane.iris.world.task.J; import com.destroystokyo.paper.profile.PlayerProfile; import org.bukkit.Bukkit; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.event.player.AsyncPlayerPreLoginEvent; import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.WorldInfo; +import org.bukkit.plugin.PluginManager; import org.junit.After; import org.junit.Rule; import org.junit.Test; @@ -16,7 +22,9 @@ import org.junit.rules.TemporaryFolder; import org.mockito.MockedStatic; import java.io.File; +import java.lang.reflect.Field; import java.net.InetAddress; +import java.util.List; import java.util.UUID; import static org.junit.Assert.assertEquals; @@ -24,6 +32,9 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -40,6 +51,7 @@ public class FailedEnableLockTest { @After public void disableValidation() { IrisStartupValidation.disable(); + IrisPlatforms.unbind(); } @Test @@ -49,7 +61,7 @@ public class FailedEnableLockTest { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - assertTrue(FailedEnableLock.engage(failure, true)); + assertTrue(FailedEnableLock.engage(failure, true, false)); String denial = IrisStartupValidation.denialReason().orElseThrow(); assertTrue(denial, denial.contains("service graph exploded")); @@ -74,7 +86,7 @@ public class FailedEnableLockTest { IrisStartupValidation.begin(); try (MockedStatic iris = mockStatic(Iris.class)) { - assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false)); + assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false, false)); } String denial = IrisStartupValidation.denialReason().orElseThrow(); @@ -84,7 +96,7 @@ public class FailedEnableLockTest { @Test public void noticeSaysIrisStaysLockedAndWhatThatBlocks() { String notice = String.join("\n", FailedEnableLock.notice( - new IllegalStateException("outer", new IllegalArgumentException("inner")))); + new IllegalStateException("outer", new IllegalArgumentException("inner")), false)); assertTrue(notice, notice.contains("Iris failed to enable")); assertTrue(notice, notice.contains("outer")); @@ -94,6 +106,82 @@ public class FailedEnableLockTest { assertTrue(notice, notice.contains("logins")); } + /** + * A hotload enables Iris on a running server whose startup worlds already exist, so nothing stops. + */ + @Test + public void noticeAfterStartupWorldsExistSaysTheServerKeepsRunning() { + String notice = String.join("\n", FailedEnableLock.notice(new IllegalStateException("outer"), true)); + + assertFalse(notice, notice.contains("startup stops")); + assertTrue(notice, notice.contains("keeps running")); + } + + /** + * IrisPlatforms.bind refuses a second platform, which is a real way for enable to throw. It has to throw inside + * the guarded region, and the throw must not reach Paper. + */ + @Test + public void onEnableKeepsIrisEnabledAndLockedWhenEnableThrowsOnAServerWithIrisWorlds() throws Exception { + IrisPlatforms.bind(mock(IrisPlatform.class)); + Iris plugin = plugin(); + + try (EnableFixture fixture = new EnableFixture(true)) { + plugin.onEnable(); + + String denial = IrisStartupValidation.denialReason().orElseThrow(); + assertTrue(denial, denial.contains("already bound")); + ChunkGenerator generator = plugin.getDefaultWorldGenerator("world", "overworld"); + assertNotNull("a null generator is the vanilla generator", generator); + IllegalStateException refusal = assertThrows(IllegalStateException.class, + () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class))); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("already bound")); + fixture.bukkit.verify(Bukkit::shutdown, never()); + } + } + + @Test + public void onEnableStillThrowsToPaperWhenTheServerHasNoIrisWorlds() throws Exception { + IrisPlatforms.bind(mock(IrisPlatform.class)); + Iris plugin = plugin(); + + try (EnableFixture fixture = new EnableFixture(false)) { + IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable); + assertTrue(failure.getMessage(), failure.getMessage().contains("already bound")); + } + } + + @Test + public void anUnsupportedServerWithIrisWorldsLocksInsteadOfDisablingIris() throws Exception { + Iris plugin = plugin(); + set(plugin, "pendingWorldReplacements", mock(PendingWorldReplacementManager.class)); + + try (EnableFixture fixture = new EnableFixture(true); + MockedStatic nms = mockStatic(INMS.class); + MockedStatic scheduler = mockStatic(J.class)) { + nms.when(INMS::isBound).thenReturn(false); + nms.when(INMS::bindFailure).thenReturn(new IllegalStateException("no NMS binding for 26.9")); + + plugin.onEnable(); + + String denial = IrisStartupValidation.denialReason().orElseThrow(); + assertTrue(denial, denial.contains("no NMS binding for 26.9")); + scheduler.verify(() -> J.s(any(Runnable.class), anyInt()), never()); + } + } + + /** + * Deferring to the server stop quiesces through the NMS binding and waits for generators a failed enable never + * made, so a failed enable tears down at once even while the server stops. + */ + @Test + public void aFailedEnableNeverDefersItsTeardownToTheServerStop() { + assertFalse(FailedEnableLock.defersTeardownToServerStop(true, true)); + assertFalse(FailedEnableLock.defersTeardownToServerStop(true, false)); + assertTrue(FailedEnableLock.defersTeardownToServerStop(false, true)); + assertFalse(FailedEnableLock.defersTeardownToServerStop(false, false)); + } + @Test public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception { File levelRoot = temporaryFolder.newFolder("level", "world"); @@ -110,4 +198,43 @@ public class FailedEnableLockTest { assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs()); assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); } + + /** + * Paper builds plugins through its class loader, so the plugin under test is the real class with its fields + * set by hand: the resolver getDefaultWorldGenerator delegates to, and whatever enable touches before failing. + */ + private static Iris plugin() throws Exception { + Iris plugin = mock(Iris.class, CALLS_REAL_METHODS); + set(plugin, "generatorResolver", new IrisWorldGeneratorResolver(plugin)); + return plugin; + } + + private static void set(Iris plugin, String name, Object value) throws Exception { + Field field = Iris.class.getDeclaredField(name); + field.setAccessible(true); + field.set(plugin, value); + } + + /** + * irisWorldsPresent reads the real server.properties, which a test has none of, so the answer is set here. + */ + private static final class EnableFixture implements AutoCloseable { + private final MockedStatic bukkit = mockStatic(Bukkit.class); + private final MockedStatic iris = mockStatic(Iris.class); + private final MockedStatic lock = mockStatic(FailedEnableLock.class, CALLS_REAL_METHODS); + + private EnableFixture(boolean irisWorlds) { + PluginManager plugins = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + lock.when(FailedEnableLock::irisWorldsPresent).thenReturn(irisWorlds); + } + + @Override + public void close() { + lock.close(); + iris.close(); + bukkit.close(); + } + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java index bf4f7c9d3..abef86a46 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/BukkitWorldReconcilerRuntimeTest.java @@ -11,12 +11,14 @@ import art.arcane.iris.world.history.GenerationHistory; import art.arcane.iris.world.history.GenerationHistoryPaths; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.world.history.GenerationManifest; +import java.io.File; import java.io.IOException; import java.lang.reflect.Constructor; import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; import org.bukkit.World; @@ -26,9 +28,11 @@ import org.junit.rules.TemporaryFolder; import org.mockito.MockedStatic; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class BukkitWorldReconcilerRuntimeTest { @@ -95,6 +99,33 @@ public class BukkitWorldReconcilerRuntimeTest { } } + /** + * The startup reconcile and /iris load create worlds through the backend, so a world Iris refuses there needs + * the same banner and operator notice as one CraftServer asked for. + */ + @Test + public void aRefusedReconcilerCreateIsReportedAsARefusal() throws Exception { + NamespacedKey key = new NamespacedKey("iris", "probe"); + IllegalStateException refusal = new IllegalStateException("Iris generation history is unusable at /srv/probe."); + Iris plugin = mock(Iris.class); + WorldRefusalReporter refusals = mock(WorldRefusalReporter.class); + when(plugin.worldRefusals()).thenReturn(refusals); + when(plugin.requireWorldGenerator("world_iris_probe", "overworld")).thenThrow(refusal); + + try (MockedStatic iris = mockStatic(Iris.class); + MockedStatic storage = mockStatic(IrisWorldStorage.class)) { + storage.when(IrisWorldStorage::levelRoot).thenReturn(new File("world")); + storage.when(() -> IrisWorldStorage.configuredWorldName(key, "world")).thenReturn("world_iris_probe"); + storage.when(() -> IrisWorldStorage.logicalName(key)).thenReturn("probe"); + + CompletableFuture created = backend(plugin).createWorld(key, "overworld", 1337L); + + ExecutionException failure = assertThrows(ExecutionException.class, created::get); + assertSame(refusal, failure.getCause()); + verify(refusals).report("world_iris_probe", key, refusal); + } + } + private RuntimeFixture fixture() throws Exception { Path container = temporaryFolder.newFolder().toPath(); Path root = container.resolve("world/dimensions/iris/probe"); @@ -134,11 +165,14 @@ public class BukkitWorldReconcilerRuntimeTest { when(dimension.getLoadKey()).thenReturn("overworld"); when(engine.getData()).thenReturn(data); when(data.getDataFolder()).thenReturn(pack.toFile()); + return new RuntimeFixture(backend(mock(Iris.class)), world, key, generator, engine, history, root, pack); + } + + private static BukkitWorldReconciler.Backend backend(Iris plugin) throws Exception { Class backendClass = Class.forName(BukkitWorldReconciler.class.getName() + "$BukkitBackend"); Constructor constructor = backendClass.getDeclaredConstructor(Iris.class); constructor.setAccessible(true); - BukkitWorldReconciler.Backend backend = (BukkitWorldReconciler.Backend) constructor.newInstance(mock(Iris.class)); - return new RuntimeFixture(backend, world, key, generator, engine, history, root, pack); + return (BukkitWorldReconciler.Backend) constructor.newInstance(plugin); } private record RuntimeFixture(BukkitWorldReconciler.Backend backend, World world, NamespacedKey key, diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java index 07e9639d7..f30538bb0 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java @@ -53,4 +53,25 @@ public class IrisFailClosedChunkGeneratorTest { assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable")); assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed")); } + + /** + * The refusal is built on the way back to CraftServer, which turns any throw into the vanilla generator, so an + * unreadable cause still has to produce a generator. + */ + @Test + public void aCauseWhoseMessageCannotBeReadStillRefuses() { + IllegalStateException unreadable = new IllegalStateException() { + @Override + public String getMessage() { + throw new UnsupportedOperationException("message unavailable"); + } + }; + + ChunkGenerator generator = IrisFailClosedChunkGenerator.refused("world_iris_moon", unreadable); + + IllegalStateException refusal = assertThrows( + IllegalStateException.class, + () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class))); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon")); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java index f9aa9ece6..59c2845c3 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisWorldGeneratorResolverTest.java @@ -464,28 +464,10 @@ public class IrisWorldGeneratorResolverTest { } } - @Test - public void refusalNoticeNamesTheWorldTheCauseChainAndWhatHappensNext() { - IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", - new IOException("Historical generated registry definition changed for iris:biomes/abc")); - - String startup = String.join("\n", IrisWorldGeneratorResolver.refusalNotice( - "world_iris_moon", "iris:moon", failure, false)); - String runtime = String.join("\n", IrisWorldGeneratorResolver.refusalNotice( - "world_iris_moon", "iris:moon", failure, true)); - - for (String notice : List.of(startup, runtime)) { - assertTrue(notice, notice.contains("world_iris_moon")); - assertTrue(notice, notice.contains("iris:moon")); - assertTrue(notice, notice.contains("Iris generation history is unusable at /srv/moon.")); - assertTrue(notice, notice.contains("Historical generated registry definition changed for iris:biomes/abc")); - assertTrue(notice, notice.contains("no chunks")); - } - assertTrue(startup, startup.contains("startup stops")); - assertTrue(runtime, runtime.contains("does not load")); - assertFalse(runtime, runtime.contains("startup stops")); - } - + /** + * A vanilla slot bound to Iris in bukkit.yml with no Iris storage stops startup, so no command can run until the + * binding is gone. /iris create is for new worlds anyway; a vanilla slot is taken over with /iris replace. + */ @Test public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception { File worldContainer = temporaryFolder.newFolder("vanilla-slot"); @@ -500,7 +482,10 @@ public class IrisWorldGeneratorResolverTest { .resolveDefaultWorldGenerator("world_nether", "overworld")); assertTrue(refusal, refusal.contains("minecraft:the_nether")); - assertTrue(refusal, refusal.contains("/iris create")); + assertTrue(refusal, refusal.contains("worlds.world_nether.generator")); + assertTrue(refusal, refusal.contains("bukkit.yml")); + assertTrue(refusal, refusal.contains("/iris replace minecraft:the_nether type=")); + assertFalse(refusal, refusal.contains("/iris create")); bukkit.verify(Bukkit::shutdown, never()); } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java new file mode 100644 index 000000000..7efd537a2 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/WorldRefusalReporterTest.java @@ -0,0 +1,145 @@ +package art.arcane.iris.world; + +import art.arcane.iris.Iris; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.world.WorldLoadEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +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; + +/** + * Whether a refused world stops startup depends on who asked for it: Paper's startup level creation aborts the + * server, a runtime create only fails its caller. The banner must not claim the wrong one. + */ +public class WorldRefusalReporterTest { + private static final NamespacedKey MOON = new NamespacedKey("iris", "moon"); + + @Test + public void aRefusalBeforeAnyWorldLoadedSaysStartupStops() { + String banner = reportAndCapture(new WorldRefusalReporter()); + + assertTrue(banner, banner.contains("'moon'")); + assertTrue(banner, banner.contains("iris:moon")); + assertTrue(banner, banner.contains("Iris generation history is unusable at /srv/moon.")); + assertTrue(banner, banner.contains("Historical generated registry definition changed for iris:biomes/abc")); + assertTrue(banner, banner.contains("no chunks")); + assertTrue(banner, banner.contains("startup stops")); + } + + /** + * Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent. + * A plugin enabling at POSTWORLD creates its worlds after that, before the first tick, and the server keeps + * running when that create fails. + */ + @Test + public void aRefusalAfterTheFirstWorldLoadSaysTheServerKeepsRunning() { + WorldRefusalReporter reporter = new WorldRefusalReporter(); + reporter.onWorldLoad(new WorldLoadEvent(world(NamespacedKey.minecraft("overworld")))); + + String banner = reportAndCapture(reporter); + + assertTrue(banner, banner.contains("keeps running")); + assertFalse(banner, banner.contains("startup stops")); + } + + @Test + public void irisEnabledAfterWorldsExistTreatsEveryRefusalAsRuntime() { + WorldRefusalReporter reporter = new WorldRefusalReporter(); + Plugin plugin = mock(Plugin.class); + PluginManager plugins = mock(PluginManager.class); + World overworld = world(NamespacedKey.minecraft("overworld")); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getWorlds).thenReturn(List.of(overworld)); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + + reporter.attach(plugin); + } + + assertTrue(reporter.startupWorldsCreated()); + verify(plugins).registerEvents(reporter, plugin); + String banner = reportAndCapture(reporter); + assertTrue(banner, banner.contains("keeps running")); + } + + @Test + public void operatorsHearAboutARefusedWorldOnJoinUntilItLoads() { + WorldRefusalReporter reporter = new WorldRefusalReporter(); + reportAndCapture(reporter); + Player operator = player(true); + Player member = player(false); + + reporter.onPlayerJoin(new PlayerJoinEvent(operator, Component.empty())); + reporter.onPlayerJoin(new PlayerJoinEvent(member, Component.empty())); + + verify(operator).sendMessage(contains("iris:moon")); + verify(member, never()).sendMessage(anyString()); + + reporter.onWorldLoad(new WorldLoadEvent(world(MOON))); + Player later = player(true); + reporter.onPlayerJoin(new PlayerJoinEvent(later, Component.empty())); + verify(later, never()).sendMessage(anyString()); + } + + @Test + public void aFailureWhileReportingNeverEscapes() { + Throwable unreadable = new IllegalStateException() { + @Override + public String getMessage() { + throw new UnsupportedOperationException("message unavailable"); + } + }; + + try (MockedStatic iris = mockStatic(Iris.class)) { + new WorldRefusalReporter().report("moon", MOON, unreadable); + } + } + + private static String reportAndCapture(WorldRefusalReporter reporter) { + IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", + new IOException("Historical generated registry definition changed for iris:biomes/abc")); + List lines = new ArrayList<>(); + try (MockedStatic iris = mockStatic(Iris.class)) { + iris.when(() -> Iris.error(anyString(), any(Object[].class))).thenAnswer(invocation -> { + lines.add(invocation.getArgument(0, String.class)); + return null; + }); + reporter.report("moon", MOON, failure); + } + return String.join("\n", lines); + } + + private static World world(NamespacedKey key) { + World world = mock(World.class); + when(world.getKey()).thenReturn(key); + when(world.getName()).thenReturn(key.getKey()); + return world; + } + + private static Player player(boolean operator) { + Player player = mock(Player.class); + when(player.isOp()).thenReturn(operator); + return player; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java index 7b4543f12..5658c9dad 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java @@ -40,6 +40,7 @@ public final class ModdedPrimaryWorldRouter { private static final Set inFlight = ConcurrentHashMap.newKeySet(); private static int tickCounter = 0; private static volatile String loginRefusal; + private static volatile String absentPrimary; private ModdedPrimaryWorldRouter() { } @@ -48,6 +49,7 @@ public final class ModdedPrimaryWorldRouter { routed.clear(); inFlight.clear(); loginRefusal = null; + absentPrimary = null; } /** @@ -99,7 +101,7 @@ public final class ModdedPrimaryWorldRouter { NativeWorld target = ModdedDimensionManager.level(server, primary); if (target == null) { if (ModdedStartup.dimensionsRestored()) { - refuseLogins(server, primary); + judgeMissingPrimary(server, primary); } return; } @@ -141,9 +143,37 @@ public final class ModdedPrimaryWorldRouter { }); } + /** + * primaryWorld is instance-wide but the dimension it names belongs to one save. Only a save whose registry lists + * it expected it here, so only that save has a primary world that failed to load. + */ + private static void judgeMissingPrimary(NativeModdedServer server, String primary) { + if (loginRefusal == null && primary.equals(absentPrimary)) { + return; + } + if (loginRefusal == null && !expectedInThisSave(server, primary)) { + absentPrimary = primary; + ModdedIrisLog.warn("Iris primary world '" + primary + "' from config/irisworldgen/modded.json does not" + + " exist in this save, so players are not routed. Create it here with /iris world replace-overworld" + + " or clear primaryWorld."); + return; + } + refuseLogins(server, primary); + } + + private static boolean expectedInThisSave(NativeModdedServer server, String primary) { + try { + return ModdedDimensionRegistryStore.get(server, primary) != null; + } catch (RuntimeException unreadable) { + ModdedIrisLog.error("Iris could not read this save's dimension registry to judge primary world '" + + primary + "'", unreadable); + return true; + } + } + /** * Players left in the overworld would generate the terrain the primary Iris world was configured to replace, so a - * primary world that did not load refuses every player instead. + * primary world this save expected and did not load refuses every player instead. */ private static void refuseLogins(NativeModdedServer server, String primary) { String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players." @@ -152,7 +182,7 @@ public final class ModdedPrimaryWorldRouter { for (String line : GenerationRefusalNotice.compose( "Iris refused player logins: primary world '" + primary + "' is not loaded", List.of("routePlayersToPrimaryWorld sends players to '" + primary - + "', which did not load at startup."), + + "', which this save's dimension registry expects but did not load at startup."), List.of( "Players are disconnected instead of playing in the overworld, so no chunks are" + " written there.", diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java index 70ad24fa7..03ec17cfc 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java @@ -37,7 +37,7 @@ public class ModdedPrimaryWorldRouterTest { } @Test - public void aMissingPrimaryWorldRefusesPlayersOnceStartupHasRestoredDimensions() { + public void aPrimaryWorldThisSaveListsButDidNotRestoreRefusesPlayers() { NativeModdedServer server = mock(NativeModdedServer.class); NativeProtocolPlayer online = mock(NativeProtocolPlayer.class); onlinePlayers(server, online); @@ -45,10 +45,12 @@ public class ModdedPrimaryWorldRouterTest { try (MockedStatic configs = mockStatic(ModdedModConfig.class); MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class); MockedStatic startup = mockStatic(ModdedStartup.class); MockedStatic log = mockStatic(ModdedIrisLog.class)) { configs.when(ModdedModConfig::get).thenReturn(routed); manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); + registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted()); startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); evaluate(server); @@ -63,6 +65,37 @@ public class ModdedPrimaryWorldRouterTest { } } + /** + * primaryWorld lives in the instance-wide modded.json, but the dimension it names lives in one save. Another + * save, a new world or a reset world folder never had it, so nothing failed there and nothing is refused. + */ + @Test + public void aPrimaryWorldThisSaveNeverHadLeavesRoutingIdleWithOneWarning() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeProtocolPlayer online = mock(NativeProtocolPlayer.class); + onlinePlayers(server, online); + ModdedModConfig routed = config(true, PRIMARY); + + try (MockedStatic configs = mockStatic(ModdedModConfig.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class); + MockedStatic startup = mockStatic(ModdedStartup.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + configs.when(ModdedModConfig::get).thenReturn(routed); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); + registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(null); + startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); + + evaluate(server); + evaluate(server); + + verify(online, never()).disconnect(anyString()); + assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + log.verify(() -> ModdedIrisLog.warn(argThat((String line) -> line != null && line.contains(PRIMARY))), times(1)); + log.verify(() -> ModdedIrisLog.error(anyString()), never()); + } + } + /** * Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is * missing then is still loading, not broken. @@ -100,8 +133,10 @@ public class ModdedPrimaryWorldRouterTest { try (MockedStatic configs = mockStatic(ModdedModConfig.class); MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class); MockedStatic startup = mockStatic(ModdedStartup.class); MockedStatic log = mockStatic(ModdedIrisLog.class)) { + registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted()); startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); configs.when(ModdedModConfig::get).thenReturn(routed); evaluate(server); @@ -119,6 +154,38 @@ public class ModdedPrimaryWorldRouterTest { } } + /** + * A registry that cannot be read cannot prove the primary world was never here, so it counts as expected. + */ + @Test + public void anUnreadableRegistryRefusesPlayersRatherThanGuessing() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeProtocolPlayer online = mock(NativeProtocolPlayer.class); + onlinePlayers(server, online); + ModdedModConfig routed = config(true, PRIMARY); + + try (MockedStatic configs = mockStatic(ModdedModConfig.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic registry = mockStatic(ModdedDimensionRegistryStore.class); + MockedStatic startup = mockStatic(ModdedStartup.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + configs.when(ModdedModConfig::get).thenReturn(routed); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); + registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)) + .thenThrow(new IllegalStateException("registry could not be read")); + startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); + + evaluate(server); + + verify(online).disconnect(contains(PRIMARY)); + assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + } + } + + private static ModdedDimensionRegistryStore.PersistentDimension persisted() { + return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L); + } + private static void evaluate(NativeModdedServer server) { for (int tick = 0; tick < TICK_INTERVAL; tick++) { ModdedPrimaryWorldRouter.tick(server);