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 new file mode 100644 index 000000000..51488991a --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/FailedEnableLock.java @@ -0,0 +1,73 @@ +package art.arcane.iris; + +import art.arcane.iris.platform.bootstrap.ServerProperties; +import art.arcane.iris.world.IrisStartupValidation; +import art.arcane.iris.world.IrisWorldStorage; +import art.arcane.iris.world.lifecycle.BukkitWorldConfiguration; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +/** + * Keeps a failed enable from handing Iris worlds to the vanilla generator. + *

+ * Paper disables a plugin whose onEnable fails, and CraftServer builds every world bukkit.yml points at a disabled + * generator plugin with the vanilla generator. On a server with Iris worlds, Iris therefore stays enabled with its + * runtime locked: the generator resolver answers every Iris world with a fail-closed generator, which stops the + * level before it exists, and the admission listener refuses logins. It only needs IrisStartupValidation and + * logging, never a service the failed enable may not have created. + */ +final class FailedEnableLock { + private 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) { + if (!irisWorldsPresent) { + return false; + } + IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure)); + List notice = notice(failure); + try { + for (String line : notice) { + Iris.error(line); + } + Iris.reportError(failure); + } catch (Throwable loggingFailure) { + notice.forEach(System.err::println); + failure.addSuppressed(loggingFailure); + failure.printStackTrace(System.err); + } + return true; + } + + static List notice(Throwable failure) { + 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.", + "Player logins are refused until this is fixed and the server restarts." + )); + } + + static boolean irisWorldsPresent() { + try { + return irisWorldsPresent(IrisWorldStorage.levelRoot(), ServerProperties.BUKKIT_YML); + } catch (Throwable unreadable) { + Iris.reportError("Could not inspect this server for Iris worlds; treating it as having them.", unreadable); + return true; + } + } + + static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException { + return IrisWorldStorage.hasManagedWorldStorage(levelRoot) + || BukkitWorldConfiguration.configuresIrisGenerator(bukkitConfiguration); + } +} 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 aefe52ff1..eb3389337 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 @@ -32,7 +32,6 @@ import art.arcane.iris.world.IrisStartupValidation; import art.arcane.iris.world.IrisStartupAdmissionListener; import art.arcane.iris.world.BukkitWorldReconciler; import art.arcane.iris.world.IrisWorldGeneratorResolver; -import art.arcane.iris.world.IrisWorldStorage; import art.arcane.iris.world.PendingWorldDeleteQueue; import art.arcane.iris.world.PendingWorldReplacementManager; import art.arcane.iris.configuration.SettingsHotloadWatch; @@ -200,6 +199,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false); private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false); private volatile boolean generatorDrainCompleted; + private volatile boolean enableFailed; private volatile PlaceholderRegistration papiRegistration; private volatile IrisPapiListener papiListener; private volatile IrisPapiState papiState; @@ -613,21 +613,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag(); } - /** - * @return false when the bootstrap was aborted (unsupported server version); the caller - * must bail out of onEnable without touching any further setup. - */ - private boolean enable() { - if (!INMS.isBound()) { - Throwable bindFailure = INMS.bindFailure(); - Iris.error("Iris cannot start: " + (bindFailure == null - ? "no NMS binding is available for this server version." - : bindFailure.getMessage())); - // Deferred one tick: disablePlugin from inside onEnable re-enters onDisable - // synchronously and the loader then continues registering the half-enabled plugin. - J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1); - return false; - } + private void enable() { EnableTimings timings = new EnableTimings(); alreadyDrained.set(false); postStopFinisherStarted.set(false); @@ -768,6 +754,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } J.s(() -> { + generatorResolver.markServerRunning(); pendingWorldReplacements.captureVanillaLevelContext(); pendingWorldReplacements.verifyLoadedPublishedWorlds(); J.a(this::bstats); @@ -788,7 +775,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { }); timings.mark("startupTasks"); timings.report(); - return true; } private static final class EnableTimings { @@ -910,20 +896,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } public void onEnable() { - IrisPlatforms.bind(new BukkitPlatform()); + enableFailed = false; IrisStartupValidation.begin(); Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); - Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this); - pendingWorldReplacements.registerPlatformEntryListener(); - boolean enabled; try { - enabled = enable(); + IrisPlatforms.bind(new BukkitPlatform()); + Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this); + pendingWorldReplacements.registerPlatformEntryListener(); + if (!INMS.isBound()) { + refuseUnsupportedServer(); + return; + } + enable(); } catch (Throwable failure) { - refuseVanillaFallback(failure); - throw failure; - } - if (!enabled) { - refuseVanillaFallback(null); + if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) { + throw failure; + } + enableFailed = true; return; } BukkitGuiHost.install(); @@ -939,54 +928,31 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { reportLockedRuntime(); } + private void refuseUnsupportedServer() { + Throwable bindFailure = INMS.bindFailure(); + 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())) { + enableFailed = true; + return; + } + Iris.error(failure.getMessage()); + // Deferred one tick: disablePlugin from inside onEnable re-enters onDisable + // synchronously and the loader then continues registering the half-enabled plugin. + J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1); + } + private static void reportLockedRuntime() { String denial = IrisStartupValidation.denialReason().orElse(null); if (denial == null) { return; } - boolean managedStorage; - try { - managedStorage = IrisWorldStorage.hasManagedWorldStorage(IrisWorldStorage.levelRoot()); - } catch (Throwable unavailable) { - Iris.reportError("Could not inspect Iris world storage while reporting the locked runtime.", unavailable); - managedStorage = false; - } - for (String line : RuntimeLockNotice.compose(denial, managedStorage)) { + for (String line : RuntimeLockNotice.compose(denial, FailedEnableLock.irisWorldsPresent())) { Iris.error(line); } } - /** - * 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 BukkitDebugDump debugDump() { return debugDump; } @@ -1014,7 +980,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } IrisLanguage.shutdown(); teardownPapi(); - boolean serverStopping = IrisToolbelt.isServerStopping(); + // 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 restartingAtStartupBoundary = startupBoundaryRestart.get(); if (restartingAtStartupBoundary) { teardownRuntime("startup-boundary-restart", 30L); @@ -1457,6 +1425,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return generatorResolver.resolveDefaultWorldGenerator(worldName, id); } + public ChunkGenerator requireWorldGenerator(String worldName, String id) { + return generatorResolver.requireWorldGenerator(worldName, id); + } + public void splash() { Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes()); printPacks(); 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 1734ce4aa..0c40281bc 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 @@ -112,16 +112,16 @@ public final class IrisBootstrap implements PluginBootstrap { * 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. + * whose folder is present is going to be loaded whatever Iris does. The generator resolver answers a world + * it cannot generate with a fail-closed generator, which stops startup at that world's own level creation, + * after the levels before it have loaded. A pack snapshot that has gone missing is found here instead, + * 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. + * or a move that failed) owns nothing to lose. Both are only reported here. */ static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException { Path levelRoot = startupPaths.levelRoot(); 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 91bba32e6..5d7fb0e72 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 @@ -728,7 +728,7 @@ public final class BukkitWorldReconciler { String logicalWorldName = IrisWorldStorage.logicalName(worldKey); String configuredWorldName = configuredWorldName(worldKey); Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension); - ChunkGenerator generator = plugin.getDefaultWorldGenerator(configuredWorldName, dimension); + ChunkGenerator generator = plugin.requireWorldGenerator(configuredWorldName, dimension); IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension); if (generator == null || irisDimension == null) { throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\"."); 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 9309e4e60..020230bac 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 @@ -18,6 +18,7 @@ package art.arcane.iris.world; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import org.bukkit.HeightMap; import org.bukkit.Location; import org.bukkit.World; @@ -48,6 +49,11 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator { + "' remains locked: " + denialReason); } + static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) { + return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + + GenerationRefusalNotice.summary(failure)); + } + @Override public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) { throw refusal(); 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 6d7e88aae..9529f5ec5 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,6 +47,7 @@ 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; @@ -90,6 +91,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; public IrisWorldGeneratorResolver(VolmitPlugin plugin) { this.plugin = plugin; @@ -480,12 +482,28 @@ public final class IrisWorldGeneratorResolver { return fallback.get(); } + /** + * The generator CraftServer asks for. Paper turns a throw or a null here into the vanilla generator, so every + * request aimed at an Iris world gets a generator back: the Iris one, or a fail-closed one that refuses to let + * the level be created. Only the PlotSquared discovery sentinel, which is never created, gets null. + */ @Nullable public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) { if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) { Iris.debug("Ignoring PlotSquared generator discovery probe"); return null; } + try { + return requireWorldGenerator(worldName, id); + } catch (Throwable failure) { + return refuseWorld(worldName, failure); + } + } + + /** + * Resolution for Iris' own loads, which report a failure per world instead of handing it to CraftServer. + */ + public ChunkGenerator requireWorldGenerator(String worldName, @Nullable String id) { if (isGeneratorDiscoveryProbe(worldName, id)) { Iris.debug("Generator discovery probe for loaded world " + worldName); return IrisFailClosedChunkGenerator.discoveryProbe(worldName); @@ -505,22 +523,56 @@ public final class IrisWorldGeneratorResolver { return startupLock; } Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id); - if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType(); - Iris.debug("Generator ID: " + id + " requested by bukkit/plugin"); + String dimension = id == null || id.isEmpty() ? IrisSettings.get().getGenerator().getDefaultWorldType() : id; + Iris.debug("Generator ID: " + dimension + " requested by bukkit/plugin"); File levelRoot = IrisWorldStorage.levelRoot(); NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot); requireWorldKeyAvailable(worldName, worldKey); requireOwnedWorld(worldName, levelRoot, worldKey); + 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; + } + + private ChunkGenerator refuseWorld(String worldName, Throwable failure) { + ChunkGenerator refused = IrisFailClosedChunkGenerator.refused(worldName, failure); try { - return resolveFrozenWorldGenerator(worldName, id); - } catch (RuntimeException failure) { - Iris.reportError("Refusing to load configured Iris world '" + worldName - + "' because its frozen world-local pack snapshot could not be used.", failure); - Bukkit.shutdown(); - throw failure; + 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); } + return refused; + } + + private static String refusalKey(String worldName) { + try { + return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName()).toString(); + } catch (Throwable unresolved) { + return "unresolved key"; + } + } + + 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 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 new file mode 100644 index 000000000..c2ec0a824 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/FailedEnableLockTest.java @@ -0,0 +1,113 @@ +package art.arcane.iris; + +import art.arcane.iris.world.IrisStartupAdmissionListener; +import art.arcane.iris.world.IrisStartupValidation; +import art.arcane.iris.world.IrisWorldGeneratorResolver; +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.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.File; +import java.net.InetAddress; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +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.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; + +/** + * Paper disables a plugin whose onEnable throws, and CraftServer then builds every world bukkit.yml points at a + * disabled generator plugin with the vanilla generator. A failed enable on a server with Iris worlds therefore has + * to leave Iris enabled and locked instead. + */ +public class FailedEnableLockTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @After + public void disableValidation() { + IrisStartupValidation.disable(); + } + + @Test + public void enableFailureWithIrisWorldsLocksTheRuntimeInsteadOfDisablingIris() throws Exception { + IrisStartupValidation.begin(); + IllegalStateException failure = new IllegalStateException("service graph exploded"); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class)) { + assertTrue(FailedEnableLock.engage(failure, true)); + + String denial = IrisStartupValidation.denialReason().orElseThrow(); + assertTrue(denial, denial.contains("service graph exploded")); + + ChunkGenerator generator = new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("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("service graph exploded")); + bukkit.verify(Bukkit::shutdown, never()); + } + + AsyncPlayerPreLoginEvent login = new AsyncPlayerPreLoginEvent( + "LockedTest", InetAddress.getLoopbackAddress(), UUID.randomUUID(), false, mock(PlayerProfile.class)); + new IrisStartupAdmissionListener().onAsyncPlayerPreLogin(login); + assertEquals(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, login.getLoginResult()); + } + + @Test + public void enableFailureWithoutIrisWorldsLeavesIrisToBeDisabled() { + IrisStartupValidation.begin(); + + try (MockedStatic iris = mockStatic(Iris.class)) { + assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false)); + } + + String denial = IrisStartupValidation.denialReason().orElseThrow(); + assertFalse(denial, denial.contains("service graph exploded")); + } + + @Test + public void noticeSaysIrisStaysLockedAndWhatThatBlocks() { + String notice = String.join("\n", FailedEnableLock.notice( + new IllegalStateException("outer", new IllegalArgumentException("inner")))); + + assertTrue(notice, notice.contains("Iris failed to enable")); + assertTrue(notice, notice.contains("outer")); + assertTrue(notice, notice.contains("inner")); + assertTrue(notice, notice.contains("vanilla")); + assertTrue(notice, notice.contains("startup stops")); + assertTrue(notice, notice.contains("logins")); + } + + @Test + public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception { + File levelRoot = temporaryFolder.newFolder("level", "world"); + File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml"); + + assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); + + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world.generator", "Iris:overworld"); + yaml.save(bukkit); + assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); + + assertTrue(bukkit.delete()); + assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs()); + assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); + } +} 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 new file mode 100644 index 000000000..07e9639d7 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/world/IrisFailClosedChunkGeneratorTest.java @@ -0,0 +1,56 @@ +package art.arcane.iris.world; + +import org.bukkit.World; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.generator.WorldInfo; +import org.junit.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * Paper 26.3 asks a plugin generator for its biome provider in MinecraftServer.createLevel and + * CraftServer.createWorld before the level exists, outside any catch. A throw there is the only thing that stops a + * broken Iris world from being created at all, so every fail-closed generator must keep throwing from it. + */ +public class IrisFailClosedChunkGeneratorTest { + @Test + public void everyFailClosedGeneratorRefusesTheBiomeProviderBeforeTheLevelExists() { + List generators = List.of( + IrisFailClosedChunkGenerator.discoveryProbe("world"), + IrisFailClosedChunkGenerator.startupLock("world", "locked for a test"), + IrisFailClosedChunkGenerator.refused("world", new IllegalStateException("history drifted"))); + + for (ChunkGenerator generator : generators) { + IllegalStateException refusal = assertThrows( + IllegalStateException.class, + () -> generator.getDefaultBiomeProvider(mock(WorldInfo.class))); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("'world'")); + assertThrows(IllegalStateException.class, () -> generator.getDefaultPopulators(mock(World.class))); + assertThrows(IllegalStateException.class, + () -> generator.getFixedSpawnLocation(mock(World.class), new Random())); + assertFalse(generator.shouldGenerateNoise()); + } + } + + @Test + public void refusalCarriesTheWholeCauseChain() { + IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", + new IOException("Historical generated registry definition changed")); + + IllegalStateException refusal = assertThrows( + IllegalStateException.class, + () -> IrisFailClosedChunkGenerator.refused("world_iris_moon", failure) + .getDefaultBiomeProvider(mock(WorldInfo.class))); + + assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon")); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable")); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed")); + } +} 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 f7f073c44..f9aa9ece6 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 @@ -24,6 +24,7 @@ import org.bukkit.generator.WorldInfo; import org.mockito.MockedStatic; import java.io.File; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -256,18 +257,12 @@ public class IrisWorldGeneratorResolverTest { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - Server server = mock(Server.class); - when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); - bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + mockServer(bukkit, worldContainer, levelRoot, List.of()); - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> new IrisWorldGeneratorResolver(null) - .resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld")); + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld")); - assertTrue(failure.getMessage(), failure.getMessage().contains("CheckingPlotSquaredGenerator")); + assertTrue(refusal, refusal.contains("CheckingPlotSquaredGenerator")); bukkit.verify(Bukkit::shutdown, never()); } } @@ -382,32 +377,26 @@ public class IrisWorldGeneratorResolverTest { } @Test - public void externalCreateWithoutIrisStorageThrowsWithoutShutdown() throws Exception { + public void externalCreateWithoutIrisStorageFailsClosedWithoutShutdown() throws Exception { File worldContainer = temporaryFolder.newFolder("external-create"); File levelRoot = new File(worldContainer, "world"); assertTrue(levelRoot.mkdirs()); try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - Server server = mock(Server.class); - when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); - bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + mockServer(bukkit, worldContainer, levelRoot, List.of()); - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> new IrisWorldGeneratorResolver(null) - .resolveDefaultWorldGenerator("mvtest", "overworld")); + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("mvtest", "overworld")); - assertTrue(failure.getMessage(), failure.getMessage().contains("mvtest")); - assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create")); + assertTrue(refusal, refusal.contains("mvtest")); + assertTrue(refusal, refusal.contains("/iris create")); bukkit.verify(Bukkit::shutdown, never()); } } @Test - public void ownedIrisWorldWithUnusableSnapshotStillStopsTheServer() throws Exception { + public void ownedIrisWorldWithUnusableSnapshotFailsClosedWithoutShutdown() throws Exception { File worldContainer = temporaryFolder.newFolder("owned-broken"); File levelRoot = new File(worldContainer, "world"); assertTrue(levelRoot.mkdirs()); @@ -415,42 +404,103 @@ public class IrisWorldGeneratorResolverTest { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - Server server = mock(Server.class); - when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); - bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + mockServer(bukkit, worldContainer, levelRoot, List.of()); - assertThrows( - IllegalStateException.class, - () -> new IrisWorldGeneratorResolver(null) - .resolveDefaultWorldGenerator("world_iris_moon", "overworld")); + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("world_iris_moon", "overworld")); - bukkit.verify(Bukkit::shutdown); + assertTrue(refusal, refusal.contains("world_iris_moon")); + bukkit.verify(Bukkit::shutdown, never()); + } + } + + /** + * The tester's failure: a world whose generation history no longer matches the live registries. CraftServer + * turns a throw here into the vanilla generator, so the history failure has to come back as a generator that + * refuses to create the level. + */ + @Test + public void unusableGenerationHistoryFailsClosedWithoutShutdown() throws Exception { + File worldContainer = temporaryFolder.newFolder("history-broken").getCanonicalFile(); + File levelRoot = new File(worldContainer, "world"); + File dimensionRoot = new File(levelRoot, "dimensions/iris/moon"); + assertTrue(new File(dimensionRoot, "iris/generation").mkdirs()); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class); + MockedStatic worlds = mockStatic(IrisWorlds.class)) { + mockServer(bukkit, worldContainer, levelRoot, List.of()); + worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L); + + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("moon", "overworld")); + + assertTrue(refusal, refusal.contains("'moon'")); + assertTrue(refusal, refusal.contains("generation history is unusable")); + bukkit.verify(Bukkit::shutdown, never()); } } @Test - public void vanillaDimensionSlotWithoutFrozenPackIsNotOwned() throws Exception { + public void internalLoadsGetTheResolutionFailureWithoutShutdown() throws Exception { + File worldContainer = temporaryFolder.newFolder("internal-load").getCanonicalFile(); + File levelRoot = new File(worldContainer, "world"); + File dimensionRoot = new File(levelRoot, "dimensions/iris/moon"); + assertTrue(new File(dimensionRoot, "iris/generation").mkdirs()); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class); + MockedStatic worlds = mockStatic(IrisWorlds.class)) { + mockServer(bukkit, worldContainer, levelRoot, List.of()); + worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> new IrisWorldGeneratorResolver(null).requireWorldGenerator("moon", "overworld")); + + assertTrue(failure.getMessage(), failure.getMessage().contains("generation history is unusable")); + bukkit.verify(Bukkit::shutdown, never()); + iris.verify(() -> Iris.error(any(String.class), any(Object[].class)), never()); + } + } + + @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")); + } + + @Test + public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception { File worldContainer = temporaryFolder.newFolder("vanilla-slot"); File levelRoot = new File(worldContainer, "world"); assertTrue(new File(levelRoot, "dimensions/minecraft/the_nether").mkdirs()); try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - Server server = mock(Server.class); - when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); - bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + mockServer(bukkit, worldContainer, levelRoot, List.of()); - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> new IrisWorldGeneratorResolver(null) - .resolveDefaultWorldGenerator("world_nether", "overworld")); + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("world_nether", "overworld")); - assertTrue(failure.getMessage(), failure.getMessage().contains("minecraft:the_nether")); - assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create")); + assertTrue(refusal, refusal.contains("minecraft:the_nether")); + assertTrue(refusal, refusal.contains("/iris create")); bukkit.verify(Bukkit::shutdown, never()); } } @@ -468,23 +518,39 @@ public class IrisWorldGeneratorResolverTest { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { - Server server = mock(Server.class); - when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); - bukkit.when(Bukkit::getServer).thenReturn(server); - bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); - bukkit.when(Bukkit::getWorlds).thenReturn(List.of(loaded)); + mockServer(bukkit, worldContainer, levelRoot, List.of(loaded)); - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> new IrisWorldGeneratorResolver(null) - .resolveDefaultWorldGenerator("irisworld", "overworld")); + String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("irisworld", "overworld")); - assertTrue(failure.getMessage(), failure.getMessage().contains("iris:irisworld")); - assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_irisworld")); + assertTrue(refusal, refusal.contains("iris:irisworld")); + assertTrue(refusal, refusal.contains("world_iris_irisworld")); bukkit.verify(Bukkit::shutdown, never()); } } + private static void mockServer(MockedStatic bukkit, File worldContainer, File levelRoot, List worlds) { + Server server = mock(Server.class); + when(server.getLevelDirectory()).thenReturn(levelRoot.toPath()); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer); + bukkit.when(Bukkit::getWorlds).thenReturn(worlds); + } + + /** + * Resolves through CraftServer's fallback and proves the result is Iris' refusal rather than vanilla: Paper + * calls getDefaultBiomeProvider before the level exists, and that throw is what stops the level being created. + */ + private static String assertFailsClosed(Supplier resolution) { + ChunkGenerator vanillaFallback = mock(ChunkGenerator.class); + ChunkGenerator selected = craftBukkitGeneratorOrFallback(resolution, vanillaFallback); + assertNotSame(vanillaFallback, selected); + IllegalStateException refusal = assertThrows( + IllegalStateException.class, + () -> selected.getDefaultBiomeProvider(mock(WorldInfo.class))); + return refusal.getMessage(); + } + private static void writeValidPack(Path packRoot) throws Exception { Files.createDirectories(packRoot.resolve("dimensions")); Files.createDirectories(packRoot.resolve("regions")); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index 33d77d171..f47c8f235 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -59,6 +59,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord; import art.arcane.iris.structure.nativegen.IrisStructurePolicy; @@ -454,11 +455,14 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat private Engine bindEngine(NativeWorld level) { requireBindingAllowed(); + // A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the + // history verification on every chunk call and fail the same way. + engineBinding.throwIfFailed(dimensionKey); try { requireGlobalStructureGeneration( NativeModdedServer.forWorld(level).generateStructures(), dimensionKey); } catch (RuntimeException error) { - engineBinding.fail(error); + recordBindFailure(level, error); throw error; } // Cache the owning level so hot paths never scan the level map to find themselves. @@ -500,7 +504,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat engineBinding.complete(created); return created; } catch (Throwable error) { - engineBinding.fail(error); + recordBindFailure(level, error); if (error instanceof RuntimeException runtimeException) { throw runtimeException; } @@ -512,6 +516,25 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat } } + private void recordBindFailure(NativeWorld level, Throwable error) { + engineBinding.fail(error); + for (String line : bindFailureNotice(level.name(), dimensionKey, error)) { + ModdedIrisLog.error(line); + } + } + + static List bindFailureNotice(String levelName, String dimensionKey, Throwable failure) { + return GenerationRefusalNotice.compose( + "Iris refused to generate " + levelName + " (generator " + dimensionKey + ")", + GenerationRefusalNotice.causes(failure), + List.of( + "Iris does not generate this level and does not let vanilla or any other generator" + + " write it; no chunks are written.", + "A startup level stops the server; a runtime dimension stays unloaded." + + " Fix the cause, then start the server again." + )); + } + private void requireCompletedShutdown(Engine current) { if (current == null || !current.isClosing() || current.isClosed()) { return; 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 0f0ab2b27..7b4543f12 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 @@ -18,12 +18,15 @@ package art.arcane.iris.modded; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; +import java.util.ArrayList; +import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -36,6 +39,7 @@ public final class ModdedPrimaryWorldRouter { private static final Set routed = ConcurrentHashMap.newKeySet(); private static final Set inFlight = ConcurrentHashMap.newKeySet(); private static int tickCounter = 0; + private static volatile String loginRefusal; private ModdedPrimaryWorldRouter() { } @@ -43,6 +47,7 @@ public final class ModdedPrimaryWorldRouter { public static void clear() { routed.clear(); inFlight.clear(); + loginRefusal = null; } /** @@ -56,6 +61,20 @@ public final class ModdedPrimaryWorldRouter { } } + /** + * Disconnects a joining player while the configured primary world is missing. + * + * @return true when the player was refused + */ + public static boolean refuseIfUnavailable(NativeProtocolPlayer player) { + String refusal = loginRefusal; + if (refusal == null || player == null) { + return false; + } + player.disconnect(refusal); + return true; + } + public static void tick(NativeModdedServer server) { if (server == null) { return; @@ -68,17 +87,23 @@ public final class ModdedPrimaryWorldRouter { ModdedModConfig config = ModdedModConfig.get(); if (!config.routePlayersToPrimaryWorld()) { + loginRefusal = null; return; } String primary = config.primaryWorld(); if (primary.isBlank()) { + loginRefusal = null; return; } NativeWorld target = ModdedDimensionManager.level(server, primary); if (target == null) { + if (ModdedStartup.dimensionsRestored()) { + refuseLogins(server, primary); + } return; } + loginRefusal = null; NativeWorld overworld = server.overworld(); if (NativeDimensionRuntime.sameWorld(target, overworld)) { return; @@ -115,4 +140,34 @@ public final class ModdedPrimaryWorldRouter { } }); } + + /** + * 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. + */ + private static void refuseLogins(NativeModdedServer server, String primary) { + String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players." + + " An operator has to check the server console."; + if (loginRefusal == null) { + 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."), + List.of( + "Players are disconnected instead of playing in the overworld, so no chunks are" + + " written there.", + "Fix the dimension (see the Iris errors above) or clear primaryWorld in" + + " config/irisworldgen/modded.json, then start the server again." + ))) { + ModdedIrisLog.error(line); + } + } + loginRefusal = refusal; + List online = new ArrayList<>(); + // Disconnecting removes the player from the list being walked, so collect first. + server.forEachPlayer(online::add); + for (NativeProtocolPlayer player : online) { + player.disconnect(refusal); + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java index 06e425ab2..f7219ff51 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java @@ -123,11 +123,11 @@ public final class ModdedProtocolHandler { } public static void onPlayerJoin(NativeProtocolPlayer player) { - if (player == null) { + if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) { return; } String sessionId = sessionId(player); - ModdedStartup.warnPackFailuresTo(player); + ModdedStartup.warnStartupFailuresTo(player); IrisSessionRegistry current = registry; ModdedProtocolTransport currentTransport = transport; if (current == null || currentTransport == null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index 80483b617..1f530cbbf 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -26,6 +26,7 @@ import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidator; import art.arcane.iris.modded.command.ModdedPackCommands; import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.world.safeguard.GenerationRefusalNotice; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; @@ -36,6 +37,7 @@ import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.util.List; import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Stream; @@ -43,6 +45,7 @@ public final class ModdedStartup { private static final int COMPAT_BOOT_KEY_CAP = 3; private static final AtomicBoolean PREPARED = new AtomicBoolean(false); private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static final List DIMENSION_FAILURES = new CopyOnWriteArrayList<>(); private ModdedStartup() { } @@ -50,6 +53,7 @@ public final class ModdedStartup { public static void reset() { PREPARED.set(false); STARTED.set(false); + DIMENSION_FAILURES.clear(); } public static void prepareForStartup() { @@ -208,7 +212,7 @@ public final class ModdedStartup { } } - private static void reinjectPersistentDimensions(NativeModdedServer server) { + static void reinjectPersistentDimensions(NativeModdedServer server) { List dimensions = ModdedDimensionRegistryStore.loadForStartup(server); if (dimensions.isEmpty()) { @@ -233,7 +237,7 @@ public final class ModdedStartup { index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(), System.currentTimeMillis() - dimensionStartedAt); } catch (Throwable e) { - ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e); + reportDimensionFailure(dimension, e); if (e instanceof OutOfMemoryError outOfMemory) { throw outOfMemory; } @@ -243,6 +247,25 @@ public final class ModdedStartup { injected, dimensions.size(), System.currentTimeMillis() - startedAt); } + private static void reportDimensionFailure(ModdedDimensionRegistryStore.PersistentDimension dimension, Throwable failure) { + List notice = GenerationRefusalNotice.compose( + "Iris refused to load dimension '" + dimension.id() + "' (pack=" + dimension.pack() + + " dim=" + dimension.dimension() + " seed=" + dimension.seed() + ")", + GenerationRefusalNotice.causes(failure), + List.of( + "Iris does not generate this dimension and does not let vanilla or any other generator" + + " write it; no chunks are written.", + "It stays unloaded. Fix the cause, then start the server again." + )); + for (String line : notice) { + ModdedIrisLog.error(line); + } + ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", + dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), failure); + DIMENSION_FAILURES.add("Iris dimension '" + dimension.id() + "' did not load and refuses to generate: " + + GenerationRefusalNotice.summary(failure)); + } + private static long newestModificationMillis(Path root) { long newest = 0L; try (Stream walk = Files.walk(root)) { @@ -261,13 +284,24 @@ public final class ModdedStartup { } /** - * SP-6: a pack excluded by validation is otherwise only visible in the console. Tell the operators who - * can actually act on it when they join. + * True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime + * dimension that is not loaded yet is still on its way. */ - public static void warnPackFailuresTo(NativeProtocolPlayer player) { + public static boolean dimensionsRestored() { + return STARTED.get(); + } + + /** + * SP-6: a pack excluded by validation or a persistent dimension that did not load is otherwise only visible in + * the console. Tell the operators who can actually act on it when they join. + */ + public static void warnStartupFailuresTo(NativeProtocolPlayer player) { if (player == null || !player.isGameMaster()) { return; } + for (String failure : DIMENSION_FAILURES) { + player.sendMessage(failure); + } for (Map.Entry entry : PackValidationRegistry.snapshot().entrySet()) { PackValidationResult result = entry.getValue(); if (result == null || result.isLoadable()) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java new file mode 100644 index 000000000..1a4ca2ccf --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java @@ -0,0 +1,105 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenerator; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.chunk.ChunkGenerator; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +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.times; +import static org.mockito.Mockito.when; + +/** + * A level whose Iris generator cannot bind must crash its own creation, never fall through to another generator, + * and every later chunk call must fail fast on the recorded failure instead of re-running the whole bind + * (history verification included) on every worker thread. + */ +public class ModdedBindFailureTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void aRecordedBindFailureIsReportedOnceAndRethrownWithoutBindingAgain() throws Exception { + IrisModdedChunkGenerator generator = generator("overworld:overworld"); + NativeWorld level = level(generator, "minecraft:overworld"); + field("boundLevel").set(generator, level); + NativeModdedServer server = mock(NativeModdedServer.class); + when(server.generateStructures()).thenReturn(false); + + try (MockedStatic servers = mockStatic(NativeModdedServer.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server); + + IllegalStateException recorded = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); + IllegalStateException second = assertThrows(IllegalStateException.class, generator::engine); + IllegalStateException third = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); + + assertSame(recorded, second.getCause()); + assertSame(recorded, third.getCause()); + servers.verify(() -> NativeModdedServer.forWorld(level), times(1)); + log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ") + && line.contains("generate-structures=false"))), times(1)); + } + } + + @Test + public void bindFailureNoticeNamesTheLevelTheCauseAndThatNothingGenerates() { + String notice = String.join("\n", IrisModdedChunkGenerator.bindFailureNotice( + "minecraft:overworld", + "overworld:overworld", + new IllegalStateException("Iris generation history is unusable for 'minecraft:overworld'", + new IOException("Historical generated registry definition changed")))); + + assertTrue(notice, notice.contains("minecraft:overworld")); + assertTrue(notice, notice.contains("overworld:overworld")); + assertTrue(notice, notice.contains("generation history is unusable")); + assertTrue(notice, notice.contains("registry definition changed")); + assertTrue(notice, notice.contains("no chunks")); + } + + private static IrisModdedChunkGenerator generator(String dimensionKey) throws ReflectiveOperationException { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS); + field("nativeGenerator").set(generator, mock(NativeModdedChunkGenerator.class, CALLS_REAL_METHODS)); + field("engineBinding").set(generator, new ModdedEngineBinding(1L, TimeUnit.SECONDS)); + field("dimensionKey").set(generator, dimensionKey); + return generator; + } + + private static NativeWorld level(IrisModdedChunkGenerator generator, String name) throws ReflectiveOperationException { + NativeWorld level = mock(NativeWorld.class); + ServerLevel handle = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + when(level.nativeHandle()).thenReturn(handle); + when(level.name()).thenReturn(name); + when(handle.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getGenerator()).thenReturn((ChunkGenerator) field("nativeGenerator").get(generator)); + return level; + } + + private static Field field(String name) throws NoSuchFieldException { + Field field = IrisModdedChunkGenerator.class.getDeclaredField(name); + field.setAccessible(true); + return field; + } +} 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 new file mode 100644 index 000000000..70ad24fa7 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java @@ -0,0 +1,142 @@ +package art.arcane.iris.modded; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import org.junit.After; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.function.Consumer; + +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.argThat; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.doAnswer; +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; + +/** + * A configured primary Iris world that did not load used to be skipped silently, leaving every player in the + * overworld generating terrain Iris was configured to replace. + */ +public class ModdedPrimaryWorldRouterTest { + private static final int TICK_INTERVAL = 20; + private static final String PRIMARY = "iris:primary"; + + @After + public void clearRouter() { + ModdedPrimaryWorldRouter.clear(); + } + + @Test + public void aMissingPrimaryWorldRefusesPlayersOnceStartupHasRestoredDimensions() { + 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 startup = mockStatic(ModdedStartup.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + configs.when(ModdedModConfig::get).thenReturn(routed); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); + startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); + + evaluate(server); + evaluate(server); + + verify(online, times(2)).disconnect(contains(PRIMARY)); + log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ") + && line.contains(PRIMARY))), times(1)); + NativeProtocolPlayer joining = mock(NativeProtocolPlayer.class); + assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(joining)); + verify(joining).disconnect(contains(PRIMARY)); + } + } + + /** + * Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is + * missing then is still loading, not broken. + */ + @Test + public void aPrimaryWorldIsNotJudgedBeforeStartupRestoresDimensions() { + 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 startup = mockStatic(ModdedStartup.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + configs.when(ModdedModConfig::get).thenReturn(routed); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); + startup.when(ModdedStartup::dimensionsRestored).thenReturn(false); + + evaluate(server); + + verify(online, never()).disconnect(anyString()); + assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + } + } + + @Test + public void refusalLiftsOnceThePrimaryWorldIsLoadedOrUnconfigured() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeWorld overworld = mock(NativeWorld.class); + when(server.overworld()).thenReturn(overworld); + onlinePlayers(server, mock(NativeProtocolPlayer.class)); + ModdedModConfig routed = config(true, PRIMARY); + ModdedModConfig unconfigured = config(true, ""); + + try (MockedStatic configs = mockStatic(ModdedModConfig.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic startup = mockStatic(ModdedStartup.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); + configs.when(ModdedModConfig::get).thenReturn(routed); + evaluate(server); + assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + + configs.when(ModdedModConfig::get).thenReturn(unconfigured); + evaluate(server); + assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + + configs.when(ModdedModConfig::get).thenReturn(routed); + evaluate(server); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(overworld); + evaluate(server); + assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class))); + } + } + + private static void evaluate(NativeModdedServer server) { + for (int tick = 0; tick < TICK_INTERVAL; tick++) { + ModdedPrimaryWorldRouter.tick(server); + } + } + + @SuppressWarnings("unchecked") + private static void onlinePlayers(NativeModdedServer server, NativeProtocolPlayer player) { + doAnswer(invocation -> { + invocation.getArgument(0, Consumer.class).accept(player); + return null; + }).when(server).forEachPlayer(any()); + } + + private static ModdedModConfig config(boolean route, String primary) { + ModdedModConfig config = mock(ModdedModConfig.class); + when(config.routePlayersToPrimaryWorld()).thenReturn(route); + when(config.primaryWorld()).thenReturn(primary); + return config; + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java new file mode 100644 index 000000000..15dc419a4 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java @@ -0,0 +1,71 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.pack.PackValidationRegistry; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; +import org.junit.After; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +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; + +/** + * A persistent dimension that cannot be re-injected is skipped, which already writes no chunks. The skip used to be + * one console line; it has to be a banner and has to reach the operators who can fix it. + */ +public class ModdedStartupReinjectFailureTest { + @After + public void resetStartup() { + ModdedStartup.reset(); + PackValidationRegistry.clear(); + } + + @Test + public void aDimensionThatFailsToReinjectIsBanneredAndReportedToOperatorsOnJoin() { + NativeModdedServer server = mock(NativeModdedServer.class); + ModdedDimensionRegistryStore.PersistentDimension moon = + new ModdedDimensionRegistryStore.PersistentDimension("iris:moon", "overworld", "overworld", 1337L); + IllegalStateException failure = new IllegalStateException("Iris generation history is unusable for 'iris:moon'"); + + try (MockedStatic store = mockStatic(ModdedDimensionRegistryStore.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + store.when(() -> ModdedDimensionRegistryStore.loadForStartup(server)).thenReturn(List.of(moon)); + manager.when(() -> ModdedDimensionManager.restorePersistent(server, "iris:moon", "overworld", "overworld", 1337L)) + .thenThrow(failure); + + ModdedStartup.reinjectPersistentDimensions(server); + + log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.contains("iris:moon") + && line.startsWith("Iris refused"))), times(1)); + log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ") + && line.contains("generation history is unusable"))), times(1)); + } + + NativeProtocolPlayer operator = mock(NativeProtocolPlayer.class); + when(operator.isGameMaster()).thenReturn(true); + NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); + ModdedStartup.warnStartupFailuresTo(operator); + ModdedStartup.warnStartupFailuresTo(player); + + verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon") + && message.contains("generation history is unusable"))); + verify(player, never()).sendMessage(anyString()); + + ModdedStartup.reset(); + NativeProtocolPlayer laterOperator = mock(NativeProtocolPlayer.class); + when(laterOperator.isGameMaster()).thenReturn(true); + ModdedStartup.warnStartupFailuresTo(laterOperator); + verify(laterOperator, never()).sendMessage(any()); + } +} diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java index 25196cae8..a34f8feda 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/BukkitWorldConfiguration.java @@ -372,8 +372,7 @@ public final class BukkitWorldConfiguration { + "\" is not a string."); } String configuredGenerator = generator.trim(); - if (!configuredGenerator.equalsIgnoreCase("Iris") - && !configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5)) { + if (!isIrisGenerator(configuredGenerator)) { continue; } @@ -395,6 +394,35 @@ public final class BukkitWorldConfiguration { return candidates; } + /** + * True when any bukkit.yml world names Iris as its generator, vanilla slots included. CraftServer asks a plugin + * for a generator only for these worlds, so they are the ones a disabled Iris would hand to vanilla. + */ + public static boolean configuresIrisGenerator(File configurationFile) throws IOException { + Path configurationPath = Objects.requireNonNull(configurationFile, "configurationFile").toPath(); + if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + synchronized (MUTATION_LOCK) { + ConfigurationSection worlds = load(configurationFile).getConfigurationSection("worlds"); + if (worlds == null) { + return false; + } + for (String worldName : worlds.getKeys(false)) { + if (worlds.get(worldName + ".generator") instanceof String generator + && isIrisGenerator(generator.trim())) { + return true; + } + } + return false; + } + } + + private static boolean isIrisGenerator(String configuredGenerator) { + return configuredGenerator.equalsIgnoreCase("Iris") + || configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5); + } + public static GeneratorReplacement replaceIfMatching( File configurationFile, String worldName, diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java b/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java index 36d3557e4..173b53e2d 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/MissingWorldStorageLog.java @@ -61,7 +61,7 @@ public final class MissingWorldStorageLog { } 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), + + " data; Iris refuses to generate it and no other generator gets it.", worldName, worldStorage), IrisLogging.format("Delete that folder, or drop the world with /iris remove world=%s delete=true.", worldName) }); diff --git a/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java new file mode 100644 index 000000000..d182d42f1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/safeguard/GenerationRefusalNotice.java @@ -0,0 +1,58 @@ +package art.arcane.iris.world.safeguard; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * The framed console block Iris prints when it refuses to generate a world. Every platform prints the same shape + * so a refusal is found the same way in any server log. + */ +public final class GenerationRefusalNotice { + private static final String RULE = "=".repeat(78); + + private GenerationRefusalNotice() { + } + + public static List compose(String headline, List causes, List consequences) { + List lines = new ArrayList<>(causes.size() + consequences.size() + 3); + lines.add(RULE); + lines.add(Objects.requireNonNull(headline, "headline")); + for (int index = 0; index < causes.size(); index++) { + lines.add((index == 0 ? "Cause: " : "Caused by: ") + causes.get(index)); + } + lines.addAll(consequences); + lines.add(RULE); + return List.copyOf(lines); + } + + /** + * Every distinct message in the cause chain, outermost first. A wrapper whose message only repeats its cause + * (CompletionException, a rethrow with the same text) adds nothing and is skipped. + */ + public static List causes(Throwable failure) { + List causes = new ArrayList<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + Throwable current = failure; + while (current != null && seen.add(current)) { + Throwable cause = current.getCause(); + String message = current.getMessage(); + boolean repeatsCause = cause != null && cause.toString().equals(message); + if (!repeatsCause) { + String text = message == null || message.isBlank() ? current.getClass().getSimpleName() : message.trim(); + if (causes.isEmpty() || !causes.getLast().equals(text)) { + causes.add(text); + } + } + current = cause; + } + return List.copyOf(causes); + } + + public static String summary(Throwable failure) { + return String.join("; ", causes(failure)); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java index f28dc9715..31fc03985 100644 --- a/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/world/lifecycle/BukkitWorldConfigurationTest.java @@ -53,6 +53,33 @@ public class BukkitWorldConfigurationTest { )), bindings); } + /** + * CraftServer only asks a plugin for a generator when bukkit.yml names it, so any world bound to Iris - a vanilla + * slot included - is one a disabled Iris would hand to the vanilla generator. + */ + @Test + public void detectsAnyWorldBoundToIrisIncludingVanillaSlots() throws Exception { + File overworld = temporaryFolder.newFile("iris-overworld.yml"); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world.generator", "iris:overworld"); + yaml.set("worlds.world_nether.generator", "Other:generator"); + yaml.save(overworld); + File bare = temporaryFolder.newFile("iris-bare.yml"); + yaml = new YamlConfiguration(); + yaml.set("worlds.world_the_end.generator", "Iris"); + yaml.save(bare); + File other = temporaryFolder.newFile("other-only.yml"); + yaml = new YamlConfiguration(); + yaml.set("worlds.world.generator", "Irish:overworld"); + yaml.set("worlds.world_nether.seed", 1337L); + yaml.save(other); + + assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(overworld)); + assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(bare)); + assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(other)); + assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(new File(temporaryFolder.getRoot(), "absent.yml"))); + } + @Test public void rejectsCustomIrisBindingWithoutSelectedDimension() throws Exception { File configuration = temporaryFolder.newFile("missing-binding-dimension.yml"); diff --git a/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java b/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java new file mode 100644 index 000000000..fed73554b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/safeguard/GenerationRefusalNoticeTest.java @@ -0,0 +1,68 @@ +package art.arcane.iris.world.safeguard; + +import org.junit.Test; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CompletionException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class GenerationRefusalNoticeTest { + @Test + public void causesListEveryDistinctMessageOutermostFirst() { + IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", + new IOException("Historical generated registry definition changed", + new IllegalArgumentException())); + + assertEquals(List.of( + "Iris generation history is unusable at /srv/moon.", + "Historical generated registry definition changed", + "IllegalArgumentException" + ), GenerationRefusalNotice.causes(failure)); + } + + @Test + public void wrappersThatOnlyRepeatTheirCauseAreSkipped() { + IllegalStateException root = new IllegalStateException("history drifted"); + CompletionException wrapper = new CompletionException(root); + + assertEquals(List.of("history drifted"), GenerationRefusalNotice.causes(wrapper)); + assertEquals(List.of("history drifted"), GenerationRefusalNotice.causes( + new IllegalStateException("history drifted", root))); + } + + @Test + public void aCyclicCauseChainTerminates() { + IllegalStateException first = new IllegalStateException("first"); + IllegalStateException second = new IllegalStateException("second", first); + first.initCause(second); + + assertEquals(List.of("first", "second"), GenerationRefusalNotice.causes(first)); + } + + @Test + public void summaryJoinsTheCausesOnOneLine() { + assertEquals("outer; inner", GenerationRefusalNotice.summary( + new IllegalStateException("outer", new IOException("inner")))); + } + + @Test + public void noticeIsFramedAndNamesCauseChainAndConsequences() { + List lines = GenerationRefusalNotice.compose( + "Iris refused to generate world 'moon' (iris:moon)", + List.of("outer", "inner"), + List.of("No chunks are written to it.", "Server startup stops before this world loads.")); + + assertEquals(lines.getFirst(), lines.getLast()); + assertTrue(lines.getFirst(), lines.getFirst().chars().allMatch(character -> character == '=')); + assertEquals(List.of( + "Iris refused to generate world 'moon' (iris:moon)", + "Cause: outer", + "Caused by: inner", + "No chunks are written to it.", + "Server startup stops before this world loads." + ), lines.subList(1, lines.size() - 1)); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java b/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java index ebf7bc19f..0838427e8 100644 --- a/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java +++ b/core/src/test/java/art/arcane/iris/world/safeguard/RuntimeLockNoticeTest.java @@ -10,8 +10,8 @@ import static org.junit.Assert.assertTrue; /** * A boot that ends in Danger Mode used to say so only in the banner. The server then ran on with every * configured Iris world bound to a generator that throws on the first chunk, and nothing after the banner - * said that out loud. {@code refuseVanillaFallback} never covers this case because it only runs when enable - * itself fails. + * said that out loud. The failed-enable lock never covers this case because it only runs when enable itself + * fails. */ public class RuntimeLockNoticeTest { @Test