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 b7ceb4e6f..a9a7d5bfb 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 @@ -87,6 +87,7 @@ import art.arcane.iris.engine.framework.PreservationRegistry; import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.object.IrisCompat; import art.arcane.iris.core.safeguard.IrisSafeguard; +import art.arcane.iris.core.safeguard.RuntimeLockNotice; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.spi.IrisLogging; @@ -305,10 +306,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return; } - StackWalker.StackFrame frame = null; + StackWalker.StackFrame frame; try { frame = DEBUG_STACK_WALKER.walk(stream -> stream.skip(1).findFirst().orElse(null)); - } catch (Throwable ignored) { + } catch (Throwable unavailable) { + frame = null; } if (frame == null) { @@ -444,7 +446,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { try { IrisSettings currentSettings = IrisSettings.settings != null ? IrisSettings.settings : IrisSettings.get(); debug = currentSettings != null && currentSettings.getGeneral().isDebug(); - } catch (Throwable ignored) { + } catch (Throwable unreadable) { debug = false; } } @@ -625,7 +627,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { MultiBurst.burst.reopen(); MultiBurst.ioBurst.reopen(); IrisLanguage.initialize(); - debugDump = BukkitDebugDump.create(this); + debugDump = BukkitDebugDump.create(this, new BukkitDebugDump.Options( + () -> true, + () -> IrisSafeguard::debugReport)); languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(), new BukkitLanguageSwitcher.Options("iris", "iris.all", DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions())); @@ -866,6 +870,30 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { .orElse("Iris startup validation requires a restart."); startupBoundaryRestart.set(true); ServerConfigurator.restartAtStartupBoundary(restartReason); + return; + } + reportLockedRuntime(); + } + + /** + * refuseVanillaFallback only ever runs when enable itself failed. A boot that enabled into Danger Mode + * keeps every configured Iris world bound to a generator that throws, and until now said so nowhere + * after the banner. + */ + 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)) { + Iris.error(line); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index d00c993aa..e2074b758 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -1104,7 +1104,8 @@ public final class PendingWorldReplacementManager implements Listener { Iris.instance.getServer().getAllowEnd() ); } catch (Throwable failure) { - Iris.debug("Could not capture vanilla-slot availability yet: " + detail(failure)); + Iris.reportError("Could not capture vanilla-slot availability at startup;" + + " world replacement staging will resolve it on demand.", failure); } } diff --git a/core/src/main/java/art/arcane/iris/core/IrisSettings.java b/core/src/main/java/art/arcane/iris/core/IrisSettings.java index 6b690d310..936a12d22 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -107,6 +107,8 @@ public class IrisSettings { try { IO.writeAll(s, new JSONObject(new Gson().toJson(loaded)).toString(4)); } catch (IOException e) { + IrisLogging.reportError("Could not rewrite " + s.getAbsolutePath() + + "; settings changes made in game will not survive a restart.", e); } } catch (Throwable ee) { // IrisLogging.reportError(ee); causes a self-reference & stackoverflow diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/CapabilitySnapshot.java b/core/src/main/java/art/arcane/iris/core/lifecycle/CapabilitySnapshot.java index 3c871d7bf..27bbab2ad 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/CapabilitySnapshot.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/CapabilitySnapshot.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.lifecycle; +import art.arcane.iris.spi.CapabilityProbe; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.scheduling.FoliaScheduler; import org.bukkit.Bukkit; @@ -202,33 +203,28 @@ public final class CapabilitySnapshot { removeLevelMethod = null; } - Method unloadWorldAsyncMethod = null; - try { - if (bukkitServer != null) { - unloadWorldAsyncMethod = CapabilityResolution.resolveMethod(bukkitServer.getClass(), "unloadWorldAsync", method -> { + Object resolvedServer = bukkitServer; + Method unloadWorldAsyncMethod = resolvedServer == null ? null : CapabilityProbe.attempt( + "server#unloadWorldAsync", + () -> CapabilityResolution.resolveMethod(resolvedServer.getClass(), "unloadWorldAsync", method -> { Class[] params = method.getParameterTypes(); return params.length == 3 && World.class.equals(params[0]) && boolean.class.equals(params[1]) && "Consumer".equals(params[2].getSimpleName()); - }); - } - } catch (Throwable ignored) { - unloadWorldAsyncMethod = null; - } + }), + null); - Method chunkAtAsyncMethod = null; - try { - chunkAtAsyncMethod = CapabilityResolution.resolveMethod(World.class, "getChunkAtAsync", method -> { - Class[] params = method.getParameterTypes(); - return params.length == 3 - && int.class.equals(params[0]) - && int.class.equals(params[1]) - && boolean.class.equals(params[2]); - }); - } catch (Throwable ignored) { - chunkAtAsyncMethod = null; - } + Method chunkAtAsyncMethod = CapabilityProbe.attempt( + "world#getChunkAtAsync", + () -> CapabilityResolution.resolveMethod(World.class, "getChunkAtAsync", method -> { + Class[] params = method.getParameterTypes(); + return params.length == 3 + && int.class.equals(params[0]) + && int.class.equals(params[1]) + && boolean.class.equals(params[2]); + }), + null); return new CapabilitySnapshot( serverFamily, @@ -436,12 +432,8 @@ public final class CapabilitySnapshot { } private static boolean hasCanvasRuntime() { - try { - Class.forName("io.canvasmc.canvas.region.WorldRegionizer"); - return true; - } catch (Throwable ignored) { - return false; - } + return CapabilityProbe.succeeds("io.canvasmc.canvas.region.WorldRegionizer", + () -> Class.forName("io.canvasmc.canvas.region.WorldRegionizer")); } private static boolean containsIgnoreCase(String value, String needle) { @@ -452,14 +444,10 @@ public final class CapabilitySnapshot { } private static Object[] resolveWorldsProvider() throws Throwable { - try { - Class worldsProviderClass = Class.forName("net.thenextlvl.worlds.api.WorldsProvider"); - Class levelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem"); - Class generatorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType"); - Object provider = Bukkit.getServicesManager().load(worldsProviderClass); - String resolution = provider == null ? "inactive(service not registered)" : "active(service=" + provider.getClass().getName() + ")"; - return new Object[]{provider, levelStemClass, generatorTypeClass, resolution}; - } catch (Throwable ignored) { + Object[] direct = CapabilityProbe.attempt("net.thenextlvl.worlds.api.WorldsProvider", + CapabilitySnapshot::loadWorldsProvider, null); + if (direct != null) { + return direct; } Collection> knownServices = Bukkit.getServicesManager().getKnownServices(); @@ -490,4 +478,13 @@ public final class CapabilitySnapshot { return new Object[]{null, null, null, "inactive(service scan found nothing)"}; } + private static Object[] loadWorldsProvider() throws ClassNotFoundException { + Class worldsProviderClass = Class.forName("net.thenextlvl.worlds.api.WorldsProvider"); + Class levelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem"); + Class generatorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType"); + Object provider = Bukkit.getServicesManager().load(worldsProviderClass); + String resolution = provider == null ? "inactive(service not registered)" : "active(service=" + provider.getClass().getName() + ")"; + return new Object[]{provider, levelStemClass, generatorTypeClass, resolution}; + } + } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java index 64e0593d6..1060de9e8 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java @@ -6,6 +6,8 @@ import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.INMSBinding; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.CapabilityProbe; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.bukkit.WorldIdentity; import art.arcane.volmlib.util.scheduling.FoliaScheduler; @@ -412,7 +414,9 @@ final class WorldLifecycleSupport { try { Method closeMethod = levelStorageAccess.getClass().getMethod("close"); closeMethod.invoke(levelStorageAccess); - } catch (Throwable ignored) { + } catch (Throwable failure) { + IrisLogging.reportError("Failed to close the level storage access; the world session lock may still be held.", + unwrap(failure)); } } @@ -712,11 +716,8 @@ final class WorldLifecycleSupport { if (server == null) { return false; } - try { - Method method = server.getClass().getMethod("isGlobalTickThread"); - return Boolean.TRUE.equals(method.invoke(server)); - } catch (Throwable ignored) { - return false; - } + return CapabilityProbe.attempt("server#isGlobalTickThread", + () -> Boolean.TRUE.equals(server.getClass().getMethod("isGlobalTickThread").invoke(server)), + Boolean.FALSE); } } diff --git a/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java b/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java index dab62c6d6..be518db36 100644 --- a/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java +++ b/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java @@ -204,8 +204,10 @@ public class MultiverseCoreLink { try { worldKey = WorldIdentity.parse(entry.getKey()); worldName = IrisWorldStorage.configuredWorldName(worldKey, levelName); - } catch (Throwable ignored) { + } catch (Throwable unreadable) { // Not a world with a Bukkit startup name; Multiverse cannot be holding an entry for it. + IrisLogging.debug("Skipping Iris world registry entry " + entry.getKey() + + " for Multiverse reconciliation: " + describe(unreadable)); continue; } try { @@ -324,8 +326,8 @@ public class MultiverseCoreLink { try { return IrisWorlds.get().getWorlds().get(worldKey.toString()); } catch (Throwable unreadable) { - IrisLogging.debug("Could not read the Iris world registry for " + worldKey + ": " - + describe(unreadable)); + IrisLogging.reportError("Could not read the Iris world registry for " + worldKey + + "; Multiverse keeps whatever generator it already recorded.", unreadable); return null; } } @@ -382,7 +384,8 @@ public class MultiverseCoreLink { IrisDimension dimension = IrisWorlds.get().getDimension(worldKey.toString(), pack); return dimension == null ? null : BukkitEnvironment.from(dimension.getEnvironment()); } catch (Throwable unreadable) { - IrisLogging.debug("Could not read the Iris dimension for " + worldKey + ": " + describe(unreadable)); + IrisLogging.reportError("Could not read the Iris dimension for " + worldKey + + "; Multiverse keeps whatever environment it already recorded.", unreadable); return null; } } diff --git a/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java b/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java index 66db137f8..14c9fc046 100644 --- a/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java +++ b/core/src/main/java/art/arcane/iris/core/link/MultiverseGuardListener.java @@ -471,7 +471,9 @@ public final class MultiverseGuardListener implements Listener { for (String line : lines) { target.sendMessage(line); } - } catch (Throwable ignored) { + } catch (Throwable undeliverable) { + IrisLogging.debug("Falling back to plain delivery for a Multiverse guard message: " + + describe(undeliverable)); for (String line : lines) { ComponentMessenger.sendSection(sender, line); } diff --git a/core/src/main/java/art/arcane/iris/core/link/WorldEditLink.java b/core/src/main/java/art/arcane/iris/core/link/WorldEditLink.java index dda67dac8..d98e0f134 100644 --- a/core/src/main/java/art/arcane/iris/core/link/WorldEditLink.java +++ b/core/src/main/java/art/arcane/iris/core/link/WorldEditLink.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.link; +import art.arcane.iris.spi.CapabilityProbe; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.volmlib.util.data.Cuboid; @@ -63,12 +64,7 @@ public class WorldEditLink { return true; } - boolean present; - try { - present = detector.getAsBoolean(); - } catch (Throwable ignored) { - return false; - } + boolean present = CapabilityProbe.attempt("WorldEdit", detector::getAsBoolean, Boolean.FALSE); if (present) { active.aquire(() -> Boolean.TRUE); diff --git a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java index 6c98df20e..4dc586eee 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java +++ b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java @@ -1,6 +1,7 @@ package art.arcane.iris.core.localization; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.spi.CapabilityProbe; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.util.director.DirectorTextResolver; @@ -212,11 +213,10 @@ public final class IrisLanguage { if (root == null) { return false; } - try { - return overrideFile(root, configuredLocale()).equals(file.getAbsoluteFile()); - } catch (RuntimeException ignored) { - return false; - } + File overrideRoot = root; + return CapabilityProbe.attempt("locale override path", + () -> overrideFile(overrideRoot, configuredLocale()).equals(file.getAbsoluteFile()), + Boolean.FALSE); } private static boolean applyReload(File root, String requestedLocale, SnapshotCapture capture) { diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java index a311eb111..41c41b45e 100644 --- a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java @@ -18,6 +18,7 @@ package art.arcane.iris.core.project; +import art.arcane.iris.spi.CapabilityProbe; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockProperty; @@ -126,13 +127,9 @@ public class SchemaBuilder { if (cached != null) { return cached; } - JSONArray built; - try { - built = new JSONArray(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames()); - } catch (Throwable e) { - IrisLogging.debug("Schema font family enumeration unavailable: " + e.getMessage()); - built = new JSONArray(); - } + JSONArray built = CapabilityProbe.attempt("java.awt font families", + () -> new JSONArray(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames()), + new JSONArray()); fontTypes = built; return built; } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/BukkitPublicRuntimeControlBackend.java b/core/src/main/java/art/arcane/iris/core/runtime/BukkitPublicRuntimeControlBackend.java index ddada2db9..1da53717c 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/BukkitPublicRuntimeControlBackend.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/BukkitPublicRuntimeControlBackend.java @@ -1,6 +1,7 @@ package art.arcane.iris.core.runtime; import art.arcane.iris.core.lifecycle.CapabilitySnapshot; +import art.arcane.iris.spi.CapabilityProbe; import io.papermc.lib.PaperLib; import org.bukkit.Chunk; import org.bukkit.World; @@ -56,14 +57,12 @@ final class BukkitPublicRuntimeControlBackend implements WorldRuntimeControlBack } if (capabilities.chunkAtAsyncMethod() != null) { - try { - Object result = capabilities.chunkAtAsyncMethod().invoke(world, chunkX, chunkZ, generate); - if (result instanceof CompletableFuture) { - @SuppressWarnings("unchecked") - CompletableFuture future = (CompletableFuture) result; - return future; - } - } catch (Throwable ignored) { + Object result = CapabilityProbe.attempt("world#getChunkAtAsync", + () -> capabilities.chunkAtAsyncMethod().invoke(world, chunkX, chunkZ, generate), null); + if (result instanceof CompletableFuture) { + @SuppressWarnings("unchecked") + CompletableFuture future = (CompletableFuture) result; + return future; } } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/PaperLikeRuntimeControlBackend.java b/core/src/main/java/art/arcane/iris/core/runtime/PaperLikeRuntimeControlBackend.java index 377113068..abacc23b5 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/PaperLikeRuntimeControlBackend.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/PaperLikeRuntimeControlBackend.java @@ -1,6 +1,8 @@ package art.arcane.iris.core.runtime; import art.arcane.iris.core.lifecycle.CapabilitySnapshot; +import art.arcane.iris.spi.CapabilityProbe; +import art.arcane.iris.spi.IrisLogging; import io.papermc.lib.PaperLib; import org.bukkit.Bukkit; import org.bukkit.Chunk; @@ -57,7 +59,9 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend if (value instanceof Number number) { return OptionalLong.of(number.longValue()); } - } catch (Throwable ignored) { + } catch (Throwable failure) { + IrisLogging.reportError("Failed to read the runtime day time of world \"" + world.getName() + + "\" through " + strategy.description() + ".", failure); } return OptionalLong.empty(); @@ -107,7 +111,9 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend } strategy.syncMethod().invoke(serverHandle); - } catch (Throwable ignored) { + } catch (Throwable failure) { + IrisLogging.reportError("Failed to push the runtime day time of world \"" + + (world == null ? "unknown" : world.getName()) + "\" to its players.", failure); } } @@ -161,42 +167,42 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend return TimeAccessStrategy.unsupported(); } - try { - Method handleMethod = resolveZeroArgMethod(world.getClass(), "getHandle"); - if (handleMethod == null) { - return TimeAccessStrategy.unsupported(); - } + return CapabilityProbe.attempt("runtime world clock", () -> resolveTimeAccess(world), + TimeAccessStrategy.unsupported()); + } - Object handle = handleMethod.invoke(world); - if (handle == null) { - return TimeAccessStrategy.unsupported(); - } - - Method readMethod = resolveZeroArgMethod(handle.getClass(), "getDayTime"); - Method writeMethod = resolveLongArgMethod(handle.getClass(), "setDayTime"); - if (readMethod != null && writeMethod != null) { - return TimeAccessStrategy.forHandle(handleMethod, readMethod, writeMethod, "runtime_handle#setDayTime"); - } - - Method levelDataMethod = resolveZeroArgMethod(handle.getClass(), "serverLevelData"); - if (levelDataMethod == null) { - levelDataMethod = resolveZeroArgMethod(handle.getClass(), "getLevelData"); - } - if (levelDataMethod != null) { - Object levelData = levelDataMethod.invoke(handle); - if (levelData != null) { - Method levelDataReadMethod = resolveZeroArgMethod(levelData.getClass(), "getDayTime"); - Method levelDataWriteMethod = resolveLongArgMethod(levelData.getClass(), "setDayTime"); - if (levelDataReadMethod != null && levelDataWriteMethod != null) { - return TimeAccessStrategy.forLevelData(handleMethod, levelDataMethod, levelDataReadMethod, levelDataWriteMethod, "world_data#setDayTime"); - } - } - } - - return TimeAccessStrategy.unsupported(handleMethod); - } catch (Throwable ignored) { + private TimeAccessStrategy resolveTimeAccess(World world) throws ReflectiveOperationException { + Method handleMethod = resolveZeroArgMethod(world.getClass(), "getHandle"); + if (handleMethod == null) { return TimeAccessStrategy.unsupported(); } + + Object handle = handleMethod.invoke(world); + if (handle == null) { + return TimeAccessStrategy.unsupported(); + } + + Method readMethod = resolveZeroArgMethod(handle.getClass(), "getDayTime"); + Method writeMethod = resolveLongArgMethod(handle.getClass(), "setDayTime"); + if (readMethod != null && writeMethod != null) { + return TimeAccessStrategy.forHandle(handleMethod, readMethod, writeMethod, "runtime_handle#setDayTime"); + } + + Method levelDataMethod = resolveZeroArgMethod(handle.getClass(), "serverLevelData"); + if (levelDataMethod == null) { + levelDataMethod = resolveZeroArgMethod(handle.getClass(), "getLevelData"); + } + if (levelDataMethod != null) { + Object levelData = levelDataMethod.invoke(handle); + if (levelData != null) { + Method levelDataReadMethod = resolveZeroArgMethod(levelData.getClass(), "getDayTime"); + Method levelDataWriteMethod = resolveLongArgMethod(levelData.getClass(), "setDayTime"); + if (levelDataReadMethod != null && levelDataWriteMethod != null) { + return TimeAccessStrategy.forLevelData(handleMethod, levelDataMethod, levelDataReadMethod, levelDataWriteMethod, "world_data#setDayTime"); + } + } + } + return TimeAccessStrategy.unsupported(handleMethod); } private static Method resolveZeroArgMethod(Class type, String name) { @@ -283,13 +289,11 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend } private static Method resolveCraftServerMethod(String name) { - try { + return CapabilityProbe.attempt("craft server#" + name, () -> { Method method = Bukkit.getServer().getClass().getMethod(name); method.setAccessible(true); return method; - } catch (Throwable ignored) { - return null; - } + }, null); } private static Method resolveServerMethod(Method serverHandleMethod, String name) { @@ -297,7 +301,7 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend return null; } - try { + return CapabilityProbe.attempt("minecraft server#" + name, () -> { Object craftServer = Bukkit.getServer(); if (craftServer == null) { return null; @@ -311,9 +315,7 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend Method method = serverHandle.getClass().getMethod(name); method.setAccessible(true); return method; - } catch (Throwable ignored) { - return null; - } + }, null); } } } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java index 3648e6bcd..d8ddb486b 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.runtime; +import art.arcane.iris.spi.CapabilityProbe; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.IrisSettings; @@ -30,7 +31,6 @@ import org.bukkit.plugin.PluginManager; import org.bukkit.util.BoundingBox; import org.bukkit.util.VoxelShape; -import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.LinkedHashSet; import java.util.Objects; @@ -192,7 +192,8 @@ public final class WorldRuntimeControlService { backend.syncTime(world); return true; } catch (Throwable e) { - IrisLogging.debug("Runtime time lock skipped for world \"" + world.getName() + "\": " + e.getMessage()); + IrisLogging.reportError("Failed to lock the clock of world \"" + world.getName() + + "\" to noon; the world keeps its own time.", e); return false; } } @@ -621,21 +622,16 @@ public final class WorldRuntimeControlService { continue; } - try { - Field field = GameRule.class.getField(name); - Object value = field.get(null); - if (value instanceof GameRule gameRule && Integer.class.equals(gameRule.getType())) { - return (GameRule) gameRule; - } - } catch (Throwable ignored) { + Object declared = CapabilityProbe.attempt("GameRule." + name, + () -> GameRule.class.getField(name).get(null), null); + if (declared instanceof GameRule gameRule && Integer.class.equals(gameRule.getType())) { + return (GameRule) gameRule; } - try { - GameRule byName = GameRule.getByName(name); - if (byName != null && Integer.class.equals(byName.getType())) { - return (GameRule) byName; - } - } catch (Throwable ignored) { + GameRule byName = CapabilityProbe.attempt("GameRule#getByName(" + name + ")", + () -> GameRule.getByName(name), null); + if (byName != null && Integer.class.equals(byName.getType())) { + return (GameRule) byName; } } @@ -657,12 +653,10 @@ public final class WorldRuntimeControlService { continue; } - try { - GameRule byName = GameRule.getByName(availableRule); - if (byName != null && Integer.class.equals(byName.getType())) { - return (GameRule) byName; - } - } catch (Throwable ignored) { + GameRule byName = CapabilityProbe.attempt("GameRule#getByName(" + availableRule + ")", + () -> GameRule.getByName(availableRule), null); + if (byName != null && Integer.class.equals(byName.getType())) { + return (GameRule) byName; } } @@ -681,21 +675,16 @@ public final class WorldRuntimeControlService { continue; } - try { - Field field = GameRule.class.getField(name); - Object value = field.get(null); - if (value instanceof GameRule gameRule && Boolean.class.equals(gameRule.getType())) { - return (GameRule) gameRule; - } - } catch (Throwable ignored) { + Object declared = CapabilityProbe.attempt("GameRule." + name, + () -> GameRule.class.getField(name).get(null), null); + if (declared instanceof GameRule gameRule && Boolean.class.equals(gameRule.getType())) { + return (GameRule) gameRule; } - try { - GameRule byName = GameRule.getByName(name); - if (byName != null && Boolean.class.equals(byName.getType())) { - return (GameRule) byName; - } - } catch (Throwable ignored) { + GameRule byName = CapabilityProbe.attempt("GameRule#getByName(" + name + ")", + () -> GameRule.getByName(name), null); + if (byName != null && Boolean.class.equals(byName.getType())) { + return (GameRule) byName; } } @@ -717,12 +706,10 @@ public final class WorldRuntimeControlService { continue; } - try { - GameRule byName = GameRule.getByName(availableRule); - if (byName != null && Boolean.class.equals(byName.getType())) { - return (GameRule) byName; - } - } catch (Throwable ignored) { + GameRule byName = CapabilityProbe.attempt("GameRule#getByName(" + availableRule + ")", + () -> GameRule.getByName(availableRule), null); + if (byName != null && Boolean.class.equals(byName.getType())) { + return (GameRule) byName; } } diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java b/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java index 326fd929d..13d96105e 100644 --- a/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java +++ b/core/src/main/java/art/arcane/iris/core/safeguard/IrisSafeguard.java @@ -1,10 +1,10 @@ package art.arcane.iris.core.safeguard; import art.arcane.iris.core.IrisStartupValidation; +import art.arcane.iris.core.safeguard.task.CheckResult; import art.arcane.iris.core.safeguard.task.Diagnostic; import art.arcane.iris.core.safeguard.task.Task; import art.arcane.iris.core.safeguard.task.Tasks; -import art.arcane.iris.core.safeguard.task.ValueWithDiagnostics; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.format.C; @@ -15,7 +15,10 @@ import java.util.List; import java.util.Map; public final class IrisSafeguard { - private static Map> results = Collections.emptyMap(); + private static final String GENERIC_LOCK_REASON = + "An Iris startup check failed. Resolve the startup errors and restart the server."; + + private static Map results = Collections.emptyMap(); private static Map context = Collections.emptyMap(); private static Map> attachment = Collections.emptyMap(); private static Mode mode = Mode.STABLE; @@ -27,40 +30,40 @@ public final class IrisSafeguard { public static void execute() { IrisStartupValidation.beginRuntimeValidation(); List tasks = Tasks.getTasks(); - LinkedHashMap> resultValues = new LinkedHashMap<>(tasks.size()); + LinkedHashMap resultValues = new LinkedHashMap<>(tasks.size()); LinkedHashMap contextValues = new LinkedHashMap<>(tasks.size()); LinkedHashMap> attachmentValues = new LinkedHashMap<>(tasks.size()); Mode currentMode = Mode.STABLE; + String lockReason = null; int issueCount = 0; for (Task task : tasks) { - ValueWithDiagnostics result; + CheckResult result; try { result = task.run(); } catch (Throwable e) { - boolean injectionFailure = "injection".equals(task.getId()); - if (injectionFailure) { - IrisStartupValidation.markRuntimeInvalid("Iris runtime injection failed. Resolve the startup errors and restart the server."); - } - IrisLogging.reportError(e); - result = new ValueWithDiagnostics<>( - injectionFailure ? Mode.UNSTABLE : Mode.WARNING, - new Diagnostic(Diagnostic.Logger.ERROR, "Error while running task " + task.getId(), e) - ); + IrisLogging.reportError("Iris startup check \"" + task.getId() + "\" failed to run.", e); + Diagnostic diagnostic = new Diagnostic(Diagnostic.Logger.ERROR, + "Error while running task " + task.getId(), e); + result = task.failureMode() == Mode.UNSTABLE + ? CheckResult.danger(failureLockReason(task), diagnostic) + : CheckResult.warning(diagnostic); } - currentMode = currentMode.highest(result.getValue()); + currentMode = currentMode.highest(result.mode()); + if (result.mode() == Mode.UNSTABLE && lockReason == null) { + lockReason = result.lockReason(); + } resultValues.put(task, result); - contextValues.put(task.getId(), result.getValue().getId()); + contextValues.put(task.getId(), result.mode().getId()); List lines = new ArrayList<>(); - for (Diagnostic diagnostic : result.getDiagnostics()) { - String[] split = diagnostic.toString().split("\\n"); - Collections.addAll(lines, split); + for (Diagnostic diagnostic : result.diagnostics()) { + Collections.addAll(lines, diagnostic.toString().split("\\n")); } attachmentValues.put(task.getId(), lines); - if (result.getValue() != Mode.STABLE) { + if (result.mode() != Mode.STABLE) { issueCount++; } } @@ -70,6 +73,15 @@ public final class IrisSafeguard { attachment = Collections.unmodifiableMap(attachmentValues); mode = currentMode; count = issueCount; + + // Danger is the lock. Recognising one check by its id and letting every other critical failure + // through was how a no-op NMS binding or a missing dimension type reached an open login queue. + if (currentMode == Mode.UNSTABLE) { + IrisStartupValidation.markRuntimeInvalid(lockReason == null ? GENERIC_LOCK_REASON : lockReason); + return; + } + + IrisStartupValidation.markRuntimeReady(); } public static Mode mode() { @@ -84,6 +96,23 @@ public final class IrisSafeguard { return attachment; } + /** + * The per-check record for {@code /iris debugdump}. Without it a support report carried the server and + * plugin state but not one word about which startup check put the server in the mode it is in. + */ + public static String debugReport() { + StringBuilder builder = new StringBuilder(); + builder.append("Startup safeguard: ").append(mode.getId()) + .append(" (").append(count).append(" issues)"); + for (Map.Entry entry : context.entrySet()) { + builder.append('\n').append(" ").append(entry.getKey()).append(": ").append(entry.getValue()); + for (String line : attachment.getOrDefault(entry.getKey(), List.of())) { + builder.append('\n').append(" ").append(line); + } + } + return builder.toString(); + } + public static void printReports() { switch (mode) { case STABLE -> IrisLogging.info(C.BLUE + "0 Conflicts found"); @@ -91,7 +120,7 @@ public final class IrisSafeguard { case UNSTABLE -> IrisLogging.error(C.DARK_RED + "%s Issues found", count); } - for (ValueWithDiagnostics value : results.values()) { + for (CheckResult value : results.values()) { // Without the stack trace: Diagnostic.Logger splits on newlines, so a trace became one log // record per frame at the diagnostic's own severity. Traces go through reportError. value.log(true, false); @@ -106,6 +135,11 @@ public final class IrisSafeguard { } } + private static String failureLockReason(Task task) { + String declared = task.failureLockReason(); + return declared == null || declared.isBlank() ? GENERIC_LOCK_REASON : declared; + } + // A log record carries a level, so a blank record renders as an empty [WARN] line and a rule of // dashes renders as a [SEVERE] one. Spacing belongs to a console, not to the server log. private static void warning() { diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/RuntimeLockNotice.java b/core/src/main/java/art/arcane/iris/core/safeguard/RuntimeLockNotice.java new file mode 100644 index 000000000..26a1ab56e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/safeguard/RuntimeLockNotice.java @@ -0,0 +1,29 @@ +package art.arcane.iris.core.safeguard; + +import java.util.List; + +/** + * The operator lines a boot prints when Iris enabled but its runtime is locked. + *

+ * The Danger banner states the mode; this states the consequence. A locked runtime that still enabled is not + * covered by the vanilla-fallback refusal - that only runs when enable itself fails - so without this the + * only evidence is the first chunk request throwing hours later. + */ +public final class RuntimeLockNotice { + private RuntimeLockNotice() { + } + + public static List compose(String denialReason, boolean hasManagedWorldStorage) { + if (denialReason == null || denialReason.isBlank()) { + return List.of(); + } + + return List.of( + "Iris enabled with a locked runtime: " + denialReason.trim(), + hasManagedWorldStorage + ? "Every configured Iris world is generation-locked and refuses to generate terrain" + + " until this is resolved and the server restarts." + : "This server has no Iris world storage, so nothing is generation-locked;" + + " world creation and player login stay refused until this is resolved."); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/task/CheckResult.java b/core/src/main/java/art/arcane/iris/core/safeguard/task/CheckResult.java new file mode 100644 index 000000000..28f8bd85b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/safeguard/task/CheckResult.java @@ -0,0 +1,47 @@ +package art.arcane.iris.core.safeguard.task; + +import art.arcane.iris.core.safeguard.Mode; + +import java.util.List; + +/** + * What one startup check found: the severity it reached, the console lines it wants printed, and - for a + * Danger result only - the reason the runtime is locked. + *

+ * The lock reason is mandatory on {@link #danger(String, Diagnostic...)} because Danger is what stops player + * login, world creation and generation, and an operator has to be told which check did that and what fixes + * it. A check cannot reach Danger without answering that. + */ +public record CheckResult(Mode mode, String lockReason, List diagnostics) { + public CheckResult { + if (mode == null) { + throw new IllegalArgumentException("A startup check result needs a mode"); + } + if (mode == Mode.UNSTABLE && (lockReason == null || lockReason.isBlank())) { + throw new IllegalArgumentException("A Danger startup check result needs a lock reason"); + } + diagnostics = List.copyOf(diagnostics); + } + + public static CheckResult stable(Diagnostic... diagnostics) { + return new CheckResult(Mode.STABLE, null, List.of(diagnostics)); + } + + public static CheckResult warning(Diagnostic... diagnostics) { + return new CheckResult(Mode.WARNING, null, List.of(diagnostics)); + } + + public static CheckResult warning(List diagnostics) { + return new CheckResult(Mode.WARNING, null, diagnostics); + } + + public static CheckResult danger(String lockReason, Diagnostic... diagnostics) { + return new CheckResult(Mode.UNSTABLE, lockReason, List.of(diagnostics)); + } + + public void log(boolean withException, boolean withStackTrace) { + for (Diagnostic diagnostic : diagnostics) { + diagnostic.log(withException, withStackTrace); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/task/Task.java b/core/src/main/java/art/arcane/iris/core/safeguard/task/Task.java index adfc428d3..a181bc863 100644 --- a/core/src/main/java/art/arcane/iris/core/safeguard/task/Task.java +++ b/core/src/main/java/art/arcane/iris/core/safeguard/task/Task.java @@ -1,47 +1,62 @@ package art.arcane.iris.core.safeguard.task; import art.arcane.iris.core.safeguard.Mode; -import art.arcane.volmlib.util.format.Form; -import java.util.Locale; import java.util.function.Supplier; +/** + * One startup check, plus the severity its own failure carries. + *

+ * A check declares whether it is advisory or critical at the point it is written, so nothing downstream has + * to recognise a check by its id to decide what a failure means. An advisory check that throws is a Warning + * and leaves the runtime open; a critical one that throws is Danger and locks the runtime with the reason it + * carries here. + */ public abstract class Task { private final String id; - private final String name; + private final Mode failureMode; + private final String failureLockReason; - public Task(String id) { - this(id, Form.capitalizeWords(id.replace(" ", "_").toLowerCase(Locale.ROOT))); - } - - public Task(String id, String name) { + protected Task(String id, Mode failureMode, String failureLockReason) { this.id = id; - this.name = name; + this.failureMode = failureMode; + this.failureLockReason = failureLockReason; } public String getId() { return id; } - public String getName() { - return name; + public Mode failureMode() { + return failureMode; } - public abstract ValueWithDiagnostics run(); + public String failureLockReason() { + return failureLockReason; + } - public static Task of(String id, String name, Supplier> action) { - return new Task(id, name) { + public abstract CheckResult run(); + + /** + * A check whose failure degrades Iris but leaves it usable. + */ + public static Task advisory(String id, Supplier action) { + return new Task(id, Mode.WARNING, null) { @Override - public ValueWithDiagnostics run() { + public CheckResult run() { return action.get(); } }; } - public static Task of(String id, Supplier> action) { - return new Task(id) { + /** + * A check whose failure means Iris cannot safely run. {@code throwLockReason} is what an operator is told + * when the check itself throws rather than returning a result. + */ + public static Task critical(String id, String throwLockReason, Supplier action) { + return new Task(id, Mode.UNSTABLE, throwLockReason) { @Override - public ValueWithDiagnostics run() { + public CheckResult run() { return action.get(); } }; diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java b/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java index cf0dc153f..79d0dc8cb 100644 --- a/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java +++ b/core/src/main/java/art/arcane/iris/core/safeguard/task/Tasks.java @@ -1,7 +1,6 @@ package art.arcane.iris.core.safeguard.task; import art.arcane.iris.BuildConstants; -import art.arcane.iris.core.IrisStartupValidation; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.IrisWorldStorage; @@ -9,46 +8,56 @@ import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.v1X.NMSBinding1X; import art.arcane.iris.core.safeguard.Mode; import art.arcane.iris.core.splash.IrisSplashComposer; +import art.arcane.iris.spi.CapabilityProbe; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.util.common.misc.getHardware; import art.arcane.iris.util.project.agent.Agent; import org.bukkit.Bukkit; import org.bukkit.Server; +import org.bukkit.plugin.Plugin; import java.util.ArrayList; -import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.stream.Collectors; public final class Tasks { - private static final Task MEMORY = Task.of("memory", () -> { + static final String NMS_LOCK_REASON = + "Iris cannot use this server's NMS runtime. Resolve the Server Version or NMS Disabled error above and restart the server."; + static final String INJECTION_LOCK_REASON = + "Iris runtime injection failed. Resolve the startup errors and restart the server."; + static final String DIMENSION_TYPE_LOCK_REASON = + "Iris dimension types were not registered. Restart the server so the registries reload."; + + private static final Set INCOMPATIBLE_PLUGINS = Set.of("dynmap", "stratos"); + + private static final Task MEMORY = Task.advisory("memory", () -> { long mem = getHardware.getProcessMemory(); if (mem >= 3072L) { - return withDiagnostics(Mode.STABLE); + return CheckResult.stable(); } if (mem > 2048L) { - return withDiagnostics(Mode.STABLE, + return CheckResult.stable( Diagnostic.Logger.INFO.create("Memory Recommendation"), - Diagnostic.Logger.INFO.create("- 3GB+ process memory is recommended for Iris."), - Diagnostic.Logger.INFO.create("- Process Memory: " + mem + " MB")); + Diagnostic.Logger.INFO.create("- A 3GB+ JVM maximum heap is recommended for Iris."), + Diagnostic.Logger.INFO.create("- JVM maximum heap: " + mem + " MB")); } - return withDiagnostics(Mode.WARNING, + return CheckResult.warning( Diagnostic.Logger.WARN.create("Low Memory"), - Diagnostic.Logger.WARN.create("- Iris is running with 2GB or less process memory."), - Diagnostic.Logger.WARN.create("- 3GB+ process memory is recommended for Iris."), - Diagnostic.Logger.WARN.create("- Process Memory: " + mem + " MB")); + Diagnostic.Logger.WARN.create("- Iris is running with a 2GB or smaller JVM maximum heap."), + Diagnostic.Logger.WARN.create("- A 3GB+ JVM maximum heap is recommended for Iris."), + Diagnostic.Logger.WARN.create("- JVM maximum heap: " + mem + " MB")); }); - private static final Task INCOMPATIBILITIES = Task.of("incompatibilities", () -> { - Set plugins = new HashSet<>(Set.of("dynmap", "Stratos")); - plugins.removeIf(name -> server().getPluginManager().getPlugin(name) == null); + private static final Task INCOMPATIBILITIES = Task.advisory("incompatibilities", () -> { + Set plugins = installedIncompatiblePlugins(); if (plugins.isEmpty()) { - return withDiagnostics(Mode.STABLE); + return CheckResult.stable(); } List diagnostics = new ArrayList<>(); @@ -58,15 +67,15 @@ public final class Tasks { Diagnostic.Logger.ERROR.create("- The plugin Dynmap is not compatible with the server."), Diagnostic.Logger.ERROR.create("- If you want to have a map plugin like Dynmap, consider Bluemap.")); } - if (plugins.contains("Stratos")) { + if (plugins.contains("stratos")) { addAllDiagnostics(diagnostics, Diagnostic.Logger.ERROR.create("Stratos"), Diagnostic.Logger.ERROR.create("- Iris is not compatible with other worldgen plugins.")); } - return withDiagnostics(Mode.WARNING, diagnostics); + return CheckResult.warning(diagnostics); }); - private static final Task SOFTWARE = Task.of("software", () -> { + private static final Task SOFTWARE = Task.advisory("software", () -> { Set supported = Set.of("canvas", "folia", "purpur", "pufferfish", "leaf", "paper", "spigot", "bukkit"); String serverName = server().getName().toLowerCase(Locale.ROOT); boolean supportedServer = isCanvasServer(); @@ -80,15 +89,15 @@ public final class Tasks { } if (supportedServer) { - return withDiagnostics(Mode.STABLE); + return CheckResult.stable(); } - return withDiagnostics(Mode.WARNING, + return CheckResult.warning( Diagnostic.Logger.WARN.create("Unsupported Server Software"), Diagnostic.Logger.WARN.create("- Please consider using Canvas, Folia, Leaf, Paper, or Purpur instead.")); }); - private static final Task VERSION = Task.of("version", () -> { + private static final Task VERSION = Task.critical("version", NMS_LOCK_REASON, () -> { String[] parts = BukkitPlatform.plugin().getDescription().getVersion().split("-"); String supportedVersions; if (parts.length >= 3) { @@ -103,84 +112,94 @@ public final class Tasks { if (!INMS.isBound()) { String cause = INMS.bindFailure() == null ? "Unknown NMS bind failure" : INMS.bindFailure().getMessage(); - return withDiagnostics(Mode.UNSTABLE, + return CheckResult.danger(NMS_LOCK_REASON, Diagnostic.Logger.ERROR.create("Server Version"), Diagnostic.Logger.ERROR.create("- " + cause), Diagnostic.Logger.ERROR.create("- Iris only supports " + supportedVersions)); } if (!(INMS.get() instanceof NMSBinding1X)) { - return withDiagnostics(Mode.STABLE); + return CheckResult.stable(); } - return withDiagnostics(Mode.UNSTABLE, + return CheckResult.danger(NMS_LOCK_REASON, Diagnostic.Logger.ERROR.create("NMS Disabled"), Diagnostic.Logger.ERROR.create("- NMS support is disabled (general.disableNMS); Iris world creation is unavailable.")); }); - private static final Task INJECTION = Task.of("injection", () -> { + private static final Task INJECTION = Task.critical("injection", INJECTION_LOCK_REASON, () -> { + if (!INMS.isBound() || INMS.get() instanceof NMSBinding1X) { + return CheckResult.danger(NMS_LOCK_REASON, + Diagnostic.Logger.ERROR.create("Code Injection"), + Diagnostic.Logger.ERROR.create("- Runtime injection was skipped because Iris has no usable NMS binding.")); + } + if (!Agent.install()) { - IrisStartupValidation.markRuntimeInvalid("Iris Java agent is unavailable. Add -javaagent:" - + Agent.AGENT_JAR.getPath() + " to the JVM arguments before -jar and restart the server."); - return withDiagnostics(Mode.UNSTABLE, + String agentLockReason = "Iris Java agent is unavailable. Add -javaagent:" + + Agent.AGENT_JAR.getPath() + " to the JVM arguments before -jar and restart the server."; + return CheckResult.danger(agentLockReason, Diagnostic.Logger.ERROR.create("Java Agent"), Diagnostic.Logger.ERROR.create("- Add -javaagent:" + Agent.AGENT_JAR.getPath() + " before -jar in your startup command and restart the server."), Diagnostic.Logger.ERROR.create("- Dynamic attachment requires -XX:+EnableDynamicAgentLoading and a host that permits JVM attachment.")); } if (!INMS.get().injectBukkit()) { - IrisStartupValidation.markRuntimeInvalid("Iris runtime injection failed. Resolve the startup errors and restart the server."); - return withDiagnostics(Mode.UNSTABLE, + return CheckResult.danger(INJECTION_LOCK_REASON, Diagnostic.Logger.ERROR.create("Code Injection"), Diagnostic.Logger.ERROR.create("- Failed to inject code. Please contact support")); } - IrisStartupValidation.markRuntimeReady(); - return withDiagnostics(Mode.STABLE); + return CheckResult.stable(); }); - private static final Task DIMENSION_TYPES = Task.of("dimensionTypes", () -> { - Set keys = IrisWorlds.get().getDimensions().map(IrisDimension::getDimensionTypeKey).collect(Collectors.toSet()); - if (!INMS.get().missingDimensionTypes(keys.toArray(String[]::new))) { - return withDiagnostics(Mode.STABLE); + private static final Task DIMENSION_TYPES = Task.critical("dimensionTypes", DIMENSION_TYPE_LOCK_REASON, () -> { + if (!INMS.isBound()) { + return CheckResult.danger(NMS_LOCK_REASON, + Diagnostic.Logger.ERROR.create("Dimension Types"), + Diagnostic.Logger.ERROR.create("- Dimension types could not be checked because Iris has no NMS binding.")); } - return withDiagnostics(Mode.UNSTABLE, + Set keys = IrisWorlds.get().getDimensions().map(IrisDimension::getDimensionTypeKey).collect(Collectors.toSet()); + if (!INMS.get().missingDimensionTypes(keys.toArray(String[]::new))) { + return CheckResult.stable(); + } + + return CheckResult.danger(DIMENSION_TYPE_LOCK_REASON, Diagnostic.Logger.ERROR.create("Dimension Types"), Diagnostic.Logger.ERROR.create("- Required Iris dimension types were not loaded."), Diagnostic.Logger.ERROR.create("- If this still happens after a restart please contact support.")); }); - private static final Task DISK_SPACE = Task.of("diskSpace", () -> { + private static final Task DISK_SPACE = Task.advisory("diskSpace", () -> { double freeGiB = IrisWorldStorage.levelRoot().getFreeSpace() / (double) 0x4000_0000; if (freeGiB > 3.0) { - return withDiagnostics(Mode.STABLE); + return CheckResult.stable(); } - return withDiagnostics(Mode.WARNING, + return CheckResult.warning( Diagnostic.Logger.WARN.create("Insufficient Disk Space"), Diagnostic.Logger.WARN.create("- 3GB of free space is required for Iris to function.")); }); - private static final Task JAVA = Task.of("java", () -> { + private static final Task JAVA = Task.advisory("java", () -> { int version = IrisSplashComposer.javaVersion(); if (version < 0) { - return withDiagnostics(Mode.WARNING, + return CheckResult.warning( Diagnostic.Logger.WARN.create("Java Runtime"), Diagnostic.Logger.WARN.create("- Java version could not be determined (java.version=" + System.getProperty("java.version") + ").")); } if (version == 25) { - return withDiagnostics(Mode.STABLE); + return CheckResult.stable(); } if (version > 25) { - return withDiagnostics(Mode.STABLE, + return CheckResult.stable( Diagnostic.Logger.INFO.create("Java Runtime"), Diagnostic.Logger.INFO.create("- Running Java " + version + ". Iris is tested primarily on Java 25.")); } - return withDiagnostics(Mode.WARNING, + return CheckResult.warning( Diagnostic.Logger.WARN.create("Unsupported Java version"), Diagnostic.Logger.WARN.create("- Java 25+ is recommended. Current runtime: Java " + version)); }); @@ -207,14 +226,36 @@ public final class Tasks { return Bukkit.getServer(); } + /** + * Matched against every registered plugin rather than asked for by exact name: a plugin.yml decides its + * own capitalization, and asking the plugin manager for "dynmap" misses a build that registers "Dynmap". + */ + private static Set installedIncompatiblePlugins() { + Set found = new LinkedHashSet<>(); + Plugin[] plugins = server().getPluginManager().getPlugins(); + if (plugins == null) { + return found; + } + for (Plugin plugin : plugins) { + String name = plugin == null ? null : plugin.getName(); + if (name == null) { + continue; + } + String normalized = name.toLowerCase(Locale.ROOT); + if (INCOMPATIBLE_PLUGINS.contains(normalized)) { + found.add(normalized); + } + } + return found; + } + private static boolean isCanvasServer() { ClassLoader loader = server().getClass().getClassLoader(); - try { - Class.forName("io.canvasmc.canvas.region.WorldRegionizer", false, loader); + if (CapabilityProbe.succeeds("io.canvasmc.canvas.region.WorldRegionizer", + () -> Class.forName("io.canvasmc.canvas.region.WorldRegionizer", false, loader))) { return true; - } catch (Throwable ignored) { - return server().getName().toLowerCase(Locale.ROOT).contains("canvas"); } + return server().getName().toLowerCase(Locale.ROOT).contains("canvas"); } private static void addAllDiagnostics(List diagnostics, Diagnostic... values) { @@ -222,13 +263,4 @@ public final class Tasks { diagnostics.add(value); } } - - private static ValueWithDiagnostics withDiagnostics(Mode mode, Diagnostic... diagnostics) { - return new ValueWithDiagnostics<>(mode, diagnostics); - } - - private static ValueWithDiagnostics withDiagnostics(Mode mode, List diagnostics) { - return new ValueWithDiagnostics<>(mode, diagnostics); - } - } diff --git a/core/src/main/java/art/arcane/iris/core/safeguard/task/ValueWithDiagnostics.java b/core/src/main/java/art/arcane/iris/core/safeguard/task/ValueWithDiagnostics.java deleted file mode 100644 index 6650cc4a0..000000000 --- a/core/src/main/java/art/arcane/iris/core/safeguard/task/ValueWithDiagnostics.java +++ /dev/null @@ -1,40 +0,0 @@ -package art.arcane.iris.core.safeguard.task; - -import java.util.List; - -public class ValueWithDiagnostics { - private final T value; - private final List diagnostics; - - public ValueWithDiagnostics(T value, List diagnostics) { - this.value = value; - this.diagnostics = List.copyOf(diagnostics); - } - - public ValueWithDiagnostics(T value, Diagnostic... diagnostics) { - this.value = value; - this.diagnostics = List.of(diagnostics); - } - - public T getValue() { - return value; - } - - public List getDiagnostics() { - return diagnostics; - } - - public void log() { - log(true, false); - } - - public void log(boolean withException) { - log(withException, false); - } - - public void log(boolean withException, boolean withStackTrace) { - for (Diagnostic diagnostic : diagnostics) { - diagnostic.log(withException, withStackTrace); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/core/service/GlobalCacheSVC.java b/core/src/main/java/art/arcane/iris/core/service/GlobalCacheSVC.java index 9b2466f18..b8aaaa6e7 100644 --- a/core/src/main/java/art/arcane/iris/core/service/GlobalCacheSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/GlobalCacheSVC.java @@ -63,7 +63,9 @@ public class GlobalCacheSVC implements IrisService { activeTrimmer.interrupt(); try { activeTrimmer.join(TRIMMER_JOIN_MS); - } catch (InterruptedException ignored) { + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + IrisLogging.warn("Interrupted while waiting for the global cache trimmer to stop."); } if (activeTrimmer.isAlive()) { IrisLogging.warn("Global cache trimmer did not stop within " + TRIMMER_JOIN_MS + "ms."); diff --git a/core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java b/core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java index bb27f660d..0cb6ed7a7 100644 --- a/core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/MultiverseSVC.java @@ -102,8 +102,10 @@ public class MultiverseSVC implements IrisService { guard = null; try { BukkitPlatform.volmitPlugin().unregisterListener(listener); - } catch (Throwable ignored) { + } catch (Throwable alreadyUnregistered) { // Shutdown ordering already unregisters every handler. + IrisLogging.debug("Multiverse guard listener was already unregistered: " + + alreadyUnregistered.getClass().getName()); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineDiagnostics.java b/core/src/main/java/art/arcane/iris/engine/EngineDiagnostics.java index c6507cd9e..7d7421363 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineDiagnostics.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineDiagnostics.java @@ -45,7 +45,8 @@ final class EngineDiagnostics { + (version == null || version.isBlank() ? "unknown" : version)); } } catch (Throwable e) { - IrisLogging.debug("Pack compat summary failed: " + e.getMessage()); + IrisLogging.reportError("Failed to report pack compatibility for an Iris world;" + + " the world keeps generating and the summary is missing.", e); } } diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java index 74c903135..c9ed90490 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationSemanticIndex.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.history; import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.io.Durability; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; @@ -1465,9 +1466,16 @@ public final class GenerationSemanticIndex { if (previousHash == null || previousHash.equals(currentHash)) { return; } + Path unreferenced = directory.resolve(fileName(regionX, regionZ, previousHash)); try { - Files.deleteIfExists(directory.resolve(fileName(regionX, regionZ, previousHash))); - } catch (IOException ignored) { + Files.deleteIfExists(unreferenced); + } catch (IOException failure) { + if (IrisLogging.warnOnce("generation-semantics-shard-delete", + "Could not delete the superseded generation semantics shard %s; stale shards accumulate on disk.", + unreferenced)) { + IrisLogging.reportError("Could not delete the superseded generation semantics shard " + + unreferenced + ".", failure); + } } } diff --git a/core/src/test/java/art/arcane/iris/core/IrisSafeguardLockPolicyTest.java b/core/src/test/java/art/arcane/iris/core/IrisSafeguardLockPolicyTest.java new file mode 100644 index 000000000..1f134d69e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/IrisSafeguardLockPolicyTest.java @@ -0,0 +1,140 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.safeguard.IrisSafeguard; +import art.arcane.iris.core.safeguard.Mode; +import art.arcane.iris.core.safeguard.task.CheckResult; +import art.arcane.iris.core.safeguard.task.Diagnostic; +import art.arcane.iris.core.safeguard.task.Task; +import art.arcane.iris.core.safeguard.task.Tasks; +import org.junit.After; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; + +/** + * Danger Mode used to be a label: only the check whose id happened to be {@code injection} locked the + * runtime, and it did so from inside a catch that string-matched that id. Every other critical failure - + * an NMS binding that could not be bound, {@code general.disableNMS}, missing dimension types - printed + * Danger and then let the boot carry on with logins open. The severity a check declares is now the whole + * decision: any Danger result locks the runtime, and every check states the lock reason an operator reads. + */ +public class IrisSafeguardLockPolicyTest { + @After + public void resetValidationAndSafeguard() { + IrisStartupValidation.disable(); + try (MockedStatic tasks = mockStatic(Tasks.class)) { + tasks.when(Tasks::getTasks).thenReturn(List.of()); + IrisSafeguard.execute(); + } + } + + @Test + public void aDangerResultFromAnyCheckLocksTheRuntime() { + Task dimensionTypes = Task.critical("dimensionTypes", "unused throw reason", + () -> CheckResult.danger("Iris dimension types were not registered. Restart the server so the registries reload.", + Diagnostic.Logger.ERROR.create("Dimension Types"))); + + execute(dimensionTypes); + + assertEquals(Mode.UNSTABLE, IrisSafeguard.mode()); + assertFalse(IrisStartupValidation.isReady()); + assertEquals("Iris dimension types were not registered. Restart the server so the registries reload.", + IrisStartupValidation.denialReason().orElseThrow()); + assertThrows(IllegalStateException.class, IrisStartupValidation::requireWorldCreationReady); + } + + @Test + public void anAdvisoryCheckThatThrowsStaysWarningAndLeavesTheRuntimeReady() { + Task memory = Task.advisory("memory", () -> { + throw new IllegalStateException("no hardware probe"); + }); + + execute(memory); + + assertEquals(Mode.WARNING, IrisSafeguard.mode()); + assertTrue(IrisStartupValidation.isReady()); + assertEquals("warning", IrisSafeguard.asContext().get("memory")); + } + + @Test + public void aCriticalCheckThatThrowsLocksWithTheReasonItDeclared() { + Task injection = Task.critical("injection", + "Iris runtime injection failed. Resolve the startup errors and restart the server.", + () -> { + throw new IllegalStateException("Java agent unavailable"); + }); + + execute(injection); + + assertEquals(Mode.UNSTABLE, IrisSafeguard.mode()); + assertEquals("Iris runtime injection failed. Resolve the startup errors and restart the server.", + IrisStartupValidation.denialReason().orElseThrow()); + } + + @Test + public void theFirstDangerCheckOwnsTheLockReason() { + Task version = Task.critical("version", "unused throw reason", + () -> CheckResult.danger("first reason", Diagnostic.Logger.ERROR.create("Server Version"))); + Task injection = Task.critical("injection", "unused throw reason", + () -> CheckResult.danger("second reason", Diagnostic.Logger.ERROR.create("Java Agent"))); + + execute(version, injection); + + assertEquals("first reason", IrisStartupValidation.denialReason().orElseThrow()); + } + + @Test + public void everyCheckStableLeavesTheRuntimeReady() { + execute(Task.advisory("memory", CheckResult::stable), Task.critical("injection", "unused", CheckResult::stable)); + + assertEquals(Mode.STABLE, IrisSafeguard.mode()); + assertTrue(IrisStartupValidation.isReady()); + } + + /** + * A Danger result has to name what the operator must fix, so a check cannot raise one without a reason. + */ + @Test + public void aDangerResultWithoutALockReasonIsRejected() { + assertThrows(IllegalArgumentException.class, + () -> CheckResult.danger(" ", Diagnostic.Logger.ERROR.create("Broken"))); + } + + /** + * The per-check modes and diagnostic lines were collected on every boot and read by nothing. + * {@code /iris debugdump} carries them now, so a support report says which check failed. + */ + @Test + public void theDebugReportNamesEveryCheckWithItsModeAndDiagnosticLines() { + Task memory = Task.advisory("memory", () -> CheckResult.warning( + Diagnostic.Logger.WARN.create("Low Memory"), + Diagnostic.Logger.WARN.create("- JVM maximum heap: 1024 MB"))); + Task injection = Task.critical("injection", "unused", CheckResult::stable); + + execute(memory, injection); + String report = IrisSafeguard.debugReport(); + + assertTrue(report, report.contains("Startup safeguard: warning")); + assertTrue(report, report.contains("memory: warning")); + assertTrue(report, report.contains("Low Memory")); + assertTrue(report, report.contains("- JVM maximum heap: 1024 MB")); + assertTrue(report, report.contains("injection: stable")); + } + + private void execute(Task... checks) { + IrisStartupValidation.begin(); + IrisStartupValidation.markDatapacksReady(); + IrisStartupValidation.markPacksReady(); + try (MockedStatic tasks = mockStatic(Tasks.class)) { + tasks.when(Tasks::getTasks).thenReturn(List.of(checks)); + IrisSafeguard.execute(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/IrisSafeguardTest.java b/core/src/test/java/art/arcane/iris/core/IrisSafeguardTest.java index c05aa7f30..a3e5eb752 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisSafeguardTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisSafeguardTest.java @@ -15,6 +15,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mockStatic; public class IrisSafeguardTest { @@ -30,9 +31,11 @@ public class IrisSafeguardTest { @Test public void injectionExceptionRevokesReadinessAndReportsUnstableRuntime() { IllegalStateException injectionFailure = new IllegalStateException("Java agent unavailable"); - Task injection = Task.of("injection", () -> { - throw injectionFailure; - }); + Task injection = Task.critical("injection", + "Iris runtime injection failed. Resolve the startup errors and restart the server.", + () -> { + throw injectionFailure; + }); IrisStartupValidation.begin(); IrisStartupValidation.markDatapacksReady(); IrisStartupValidation.markPacksReady(); @@ -53,7 +56,7 @@ public class IrisSafeguardTest { assertEquals("Iris runtime injection failed. Resolve the startup errors and restart the server.", IrisStartupValidation.denialReason().orElseThrow()); assertThrows(IllegalStateException.class, IrisStartupValidation::requireWorldCreationReady); - logging.verify(() -> IrisLogging.reportError(injectionFailure)); + logging.verify(() -> IrisLogging.reportError(anyString(), org.mockito.ArgumentMatchers.same(injectionFailure))); } } } diff --git a/core/src/test/java/art/arcane/iris/core/IrisSettingsRewriteFailureTest.java b/core/src/test/java/art/arcane/iris/core/IrisSettingsRewriteFailureTest.java new file mode 100644 index 000000000..5acea5075 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/IrisSettingsRewriteFailureTest.java @@ -0,0 +1,79 @@ +package art.arcane.iris.core; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; + +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.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Iris rewrites iris.json on every read so a file written by an older build gains the keys it is missing. + * That write used to fail into an empty catch, which is how a read-only or full data folder turned every + * settings change an operator made in game into something that silently vanished on the next boot. + */ +public class IrisSettingsRewriteFailureTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisPlatform capturingPlatform; + private IrisPlatform previousPlatform; + private IrisSettings previousSettings; + + @Before + public void captureReports() { + previousSettings = IrisSettings.settings; + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + capturingPlatform = mock(IrisPlatform.class); + IrisPlatforms.bind(capturingPlatform); + } + + @After + public void restore() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + IrisSettings.settings = previousSettings; + } + + @Test + public void anUnwritableSettingsFileIsReported() throws Exception { + File settingsFile = temporaryFolder.newFile("iris.json"); + Files.writeString(settingsFile.toPath(), "{}"); + assertTrue("this test needs a file the JVM can mark read-only", settingsFile.setWritable(false)); + doReturn(settingsFile).when(capturingPlatform).dataFile("iris.json"); + + IrisSettings.invalidate(); + IrisSettings.get(); + + verify(capturingPlatform).reportError(contains(settingsFile.getAbsolutePath()), any(Throwable.class)); + } + + @Test + public void aWritableSettingsFileIsRewrittenQuietly() throws Exception { + File settingsFile = temporaryFolder.newFile("iris.json"); + Files.writeString(settingsFile.toPath(), "{}"); + doReturn(settingsFile).when(capturingPlatform).dataFile("iris.json"); + + IrisSettings.invalidate(); + IrisSettings.get(); + + verify(capturingPlatform, never()).reportError(anyString(), any(Throwable.class)); + assertTrue(Files.readString(settingsFile.toPath()).contains("general")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupportCloseFailureTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupportCloseFailureTest.java new file mode 100644 index 000000000..6fa5b55e3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupportCloseFailureTest.java @@ -0,0 +1,66 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * A level storage access that will not close still holds the world's session lock, and the next attempt to + * open that world fails with a lock message that names no cause. The close failure used to be swallowed + * whole, so the console had nothing to connect the two. + */ +public class WorldLifecycleSupportCloseFailureTest { + private IrisPlatform capturingPlatform; + private IrisPlatform previousPlatform; + + @Before + public void captureReports() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + capturingPlatform = mock(IrisPlatform.class); + IrisPlatforms.bind(capturingPlatform); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void aFailedStorageCloseIsReportedWithItsCause() { + WorldLifecycleSupport.closeLevelStorageAccess(new FailingStorageAccess()); + + ArgumentCaptor reported = ArgumentCaptor.forClass(Throwable.class); + verify(capturingPlatform).reportError(anyString(), reported.capture()); + assertEquals("session lock still held", reported.getValue().getMessage()); + } + + @Test + public void aClosableStorageAccessReportsNothing() { + WorldLifecycleSupport.closeLevelStorageAccess(new ClosableStorageAccess()); + + verify(capturingPlatform, org.mockito.Mockito.never()).reportError(anyString(), org.mockito.ArgumentMatchers.any()); + } + + public static final class FailingStorageAccess { + public void close() { + throw new IllegalStateException("session lock still held"); + } + } + + public static final class ClosableStorageAccess { + public void close() { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java index eae476848..efdfd4fd4 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceTimeLockTest.java @@ -2,7 +2,10 @@ package art.arcane.iris.core.runtime; import art.arcane.iris.core.lifecycle.CapabilitySnapshot; import art.arcane.iris.core.lifecycle.ServerFamily; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.testsupport.BukkitTestServer; +import org.mockito.ArgumentCaptor; import org.bukkit.Bukkit; import org.bukkit.World; import org.junit.Before; @@ -15,7 +18,11 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; public class WorldRuntimeControlServiceTimeLockTest { @Before @@ -47,6 +54,32 @@ public class WorldRuntimeControlServiceTimeLockTest { assertTrue(setTimeCalled.get()); } + /** + * A runtime that refuses the clock write used to leave a single debug line with no trace, so a world + * stuck at night looked like a pack setting rather than a rejected reflective call. + */ + @Test + public void reportsTheFailureWhenTheRuntimeClockSetterThrows() throws Exception { + IrisPlatform previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + IrisPlatform capturingPlatform = mock(IrisPlatform.class); + IrisPlatforms.bind(capturingPlatform); + try { + World world = createWorldProxy("rejecting-clock", false, new AtomicBoolean(false), new AtomicLong(0L), true); + + assertFalse(createService().applyNoonTimeLock(world)); + + ArgumentCaptor reported = ArgumentCaptor.forClass(Throwable.class); + verify(capturingPlatform).reportError(contains("rejecting-clock"), reported.capture()); + assertNotNull(reported.getValue()); + } finally { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + } + @Test public void appliesTimeLockWhenWorldHasMutableClock() throws Exception { AtomicBoolean setTimeCalled = new AtomicBoolean(false); diff --git a/core/src/test/java/art/arcane/iris/core/safeguard/RuntimeLockNoticeTest.java b/core/src/test/java/art/arcane/iris/core/safeguard/RuntimeLockNoticeTest.java new file mode 100644 index 000000000..7ffe4ca6e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/safeguard/RuntimeLockNoticeTest.java @@ -0,0 +1,42 @@ +package art.arcane.iris.core.safeguard; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +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. + */ +public class RuntimeLockNoticeTest { + @Test + public void aReadyRuntimeSaysNothing() { + assertTrue(RuntimeLockNotice.compose(null, true).isEmpty()); + assertTrue(RuntimeLockNotice.compose(" ", true).isEmpty()); + } + + @Test + public void aLockedRuntimeWithIrisWorldsNamesTheReasonAndWhatItBlocks() { + List lines = RuntimeLockNotice.compose("Iris runtime injection failed.", true); + + assertEquals(2, lines.size()); + assertEquals("Iris enabled with a locked runtime: Iris runtime injection failed.", lines.getFirst()); + assertEquals("Every configured Iris world is generation-locked and refuses to generate terrain" + + " until this is resolved and the server restarts.", lines.get(1)); + } + + @Test + public void aLockedRuntimeWithoutIrisWorldsSaysOnlyWhatIsBlocked() { + List lines = RuntimeLockNotice.compose("Iris runtime injection failed.", false); + + assertEquals(2, lines.size()); + assertEquals("Iris enabled with a locked runtime: Iris runtime injection failed.", lines.getFirst()); + assertEquals("This server has no Iris world storage, so nothing is generation-locked;" + + " world creation and player login stay refused until this is resolved.", lines.get(1)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/safeguard/TasksIncompatibilityTest.java b/core/src/test/java/art/arcane/iris/core/safeguard/TasksIncompatibilityTest.java new file mode 100644 index 000000000..d5efcbf68 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/safeguard/TasksIncompatibilityTest.java @@ -0,0 +1,87 @@ +package art.arcane.iris.core.safeguard; + +import art.arcane.iris.core.safeguard.task.CheckResult; +import art.arcane.iris.core.safeguard.task.Diagnostic; +import art.arcane.iris.core.safeguard.task.Task; +import art.arcane.iris.core.safeguard.task.Tasks; +import art.arcane.iris.testsupport.BukkitTestServer; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +/** + * The incompatibility check used to ask the plugin manager for the exact strings "dynmap" and "Stratos". + * Both plugins register under a different case than that - Dynmap's plugin.yml name is "dynmap" on some + * builds and "Dynmap" on others - so the check silently passed on the servers it exists for. + */ +public class TasksIncompatibilityTest { + private PluginManager pluginManager; + + @Before + public void installServer() { + BukkitTestServer.install(); + pluginManager = Bukkit.getServer().getPluginManager(); + doReturn(new Plugin[0]).when(pluginManager).getPlugins(); + } + + @After + public void clearPlugins() { + doReturn(new Plugin[0]).when(pluginManager).getPlugins(); + } + + @Test + public void anIncompatiblePluginIsFoundWhateverCaseItRegistersUnder() { + doReturn(new Plugin[]{namedPlugin("DynMap")}).when(pluginManager).getPlugins(); + + CheckResult result = incompatibilities().run(); + + assertEquals(Mode.WARNING, result.mode()); + assertTrue(headings(result).toString(), headings(result).contains("Dynmap")); + } + + @Test + public void aSecondIncompatiblePluginIsReportedTogetherWithTheFirst() { + doReturn(new Plugin[]{namedPlugin("stratos"), namedPlugin("dynmap")}).when(pluginManager).getPlugins(); + + CheckResult result = incompatibilities().run(); + + assertEquals(Mode.WARNING, result.mode()); + assertTrue(headings(result).toString(), headings(result).containsAll(List.of("Dynmap", "Stratos"))); + } + + @Test + public void aCleanPluginSetIsStable() { + doReturn(new Plugin[]{namedPlugin("BlueMap")}).when(pluginManager).getPlugins(); + + assertEquals(Mode.STABLE, incompatibilities().run().mode()); + } + + private static Task incompatibilities() { + for (Task task : Tasks.getTasks()) { + if ("incompatibilities".equals(task.getId())) { + return task; + } + } + throw new IllegalStateException("The incompatibilities check is missing from the safeguard task list"); + } + + private static List headings(CheckResult result) { + return result.diagnostics().stream().map(Diagnostic::getMessage).toList(); + } + + private static Plugin namedPlugin(String name) { + Plugin plugin = mock(Plugin.class); + doReturn(name).when(plugin).getName(); + return plugin; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/EngineDiagnosticsFailureTest.java b/core/src/test/java/art/arcane/iris/engine/EngineDiagnosticsFailureTest.java new file mode 100644 index 000000000..78cc2cfe0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/EngineDiagnosticsFailureTest.java @@ -0,0 +1,53 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The pack-compat summary is the line that tells an operator a pack cannot generate on the running + * Minecraft version. It must never take a working world down, but a failure to produce it used to leave + * only a debug line with no class and no trace, so the missing summary looked like "the pack is fine". + */ +public class EngineDiagnosticsFailureTest { + private IrisPlatform capturingPlatform; + private IrisPlatform previousPlatform; + + @Before + public void captureReports() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + capturingPlatform = mock(IrisPlatform.class); + IrisPlatforms.bind(capturingPlatform); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void aFailedCompatSummaryIsReportedWithItsCauseAndDoesNotThrow() { + IrisEngine engine = mock(IrisEngine.class); + IllegalStateException failure = new IllegalStateException("engine data unavailable"); + when(engine.getData()).thenThrow(failure); + + new EngineDiagnostics(engine).logPackCompatSummary(); + + ArgumentCaptor reported = ArgumentCaptor.forClass(Throwable.class); + verify(capturingPlatform).reportError(anyString(), reported.capture()); + assertEquals(failure, reported.getValue()); + } +} diff --git a/core/src/test/java/art/arcane/iris/purity/SwallowedFailurePolicyTest.java b/core/src/test/java/art/arcane/iris/purity/SwallowedFailurePolicyTest.java new file mode 100644 index 000000000..674b75b29 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/purity/SwallowedFailurePolicyTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.purity; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.Assert.assertTrue; + +/** + * A catch whose body is empty says nothing about whether the failure was expected. On world lifecycle, + * world replacement, runtime control, safeguard, service enable and disable, and generation paths that is + * the difference between a diagnosable server and a silent one, so these packages carry none: an optional + * API that is genuinely absent goes through {@code CapabilityProbe}, and everything else reports. + */ +public class SwallowedFailurePolicyTest { + private static final List PROTECTED_ROOTS = List.of( + "src/main/java/art/arcane/iris/core/runtime", + "src/main/java/art/arcane/iris/core/lifecycle", + "src/main/java/art/arcane/iris/core/safeguard", + "src/main/java/art/arcane/iris/core/link", + "src/main/java/art/arcane/iris/core/service", + "src/main/java/art/arcane/iris/core/localization", + "src/main/java/art/arcane/iris/core/structure/authoring", + "src/main/java/art/arcane/iris/core/IrisSettings.java", + "src/main/java/art/arcane/iris/engine/decorator", + "src/main/java/art/arcane/iris/engine/history", + "../spi/src/main/java", + "../adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java", + "../adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java" + ); + private static final Pattern IGNORED_CATCH = Pattern.compile("^\\s*}?\\s*catch \\([^)]*\\)\\s*\\{\\s*$"); + + @Test + public void noProtectedPathEndsAFailureInAnEmptyCatch() throws IOException { + List offenders = new ArrayList<>(); + for (Path file : protectedSources()) { + List lines = Files.readAllLines(file); + for (int i = 0; i < lines.size() - 1; i++) { + Matcher matcher = IGNORED_CATCH.matcher(lines.get(i)); + if (matcher.matches() && "}".equals(lines.get(i + 1).trim())) { + offenders.add(file + ":" + (i + 1) + " " + lines.get(i).trim()); + } + } + } + + assertTrue("Empty catch blocks on protected paths: " + String.join("\n", offenders), offenders.isEmpty()); + } + + /** + * The reporting front door is what makes a failure visible, so a protected path must not print a + * caught throwable's message on its own and call that handled. + */ + @Test + public void theProbeHelperIsTheOnlyQuietFailurePath() throws IOException { + List offenders = new ArrayList<>(); + for (Path file : protectedSources()) { + String source = Files.readString(file); + if (source.contains("catch (Throwable ignored)") || source.contains("catch (Exception ignored)")) { + offenders.add(file.toString()); + } + } + + assertTrue("Protected paths still name a caught failure \"ignored\": " + String.join("\n", offenders), + offenders.isEmpty()); + } + + private static List protectedSources() throws IOException { + List files = new ArrayList<>(); + for (String root : PROTECTED_ROOTS) { + Path path = Path.of(root); + assertTrue("Protected root is missing: " + root, Files.exists(path)); + if (Files.isRegularFile(path)) { + files.add(path); + continue; + } + try (Stream walk = Files.walk(path)) { + walk.filter(candidate -> candidate.toString().endsWith(".java")).forEach(files::add); + } + } + return files; + } +} diff --git a/core/src/test/java/art/arcane/iris/spi/CapabilityProbeTest.java b/core/src/test/java/art/arcane/iris/spi/CapabilityProbeTest.java new file mode 100644 index 000000000..747a884a9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/spi/CapabilityProbeTest.java @@ -0,0 +1,136 @@ +package art.arcane.iris.spi; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + +/** + * An optional API that is not there is not a failure, but a bare ignored catch makes it impossible to tell + * that miss apart from a real breakage. The probe keeps the miss quiet at INFO and states the failure class + * and message at DEBUG, so an operator running with debug on can see exactly what was not found. + */ +public class CapabilityProbeTest { + private final List levels = new ArrayList<>(); + private final List messages = new ArrayList<>(); + private IrisPlatform previousPlatform; + + @Before + public void captureLog() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + levels.clear(); + messages.clear(); + bindCapturingPlatform(); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void aFailedProbeNamesTheFailureClassAndMessageAtDebug() { + String resolved = CapabilityProbe.attempt("world#getChunkAtAsync", + () -> { + throw new NoSuchMethodException("getChunkAtAsync"); + }, + null); + + assertNull(resolved); + assertEquals(List.of(LogLevel.DEBUG), levels); + assertEquals("Capability probe \"world#getChunkAtAsync\" is unavailable: " + + "java.lang.NoSuchMethodException: getChunkAtAsync", messages.getFirst()); + } + + @Test + public void aFailedProbeReturnsTheUnavailableValue() { + String resolved = CapabilityProbe.attempt("optional", () -> { + throw new NoClassDefFoundError("net/example/Missing"); + }, "absent"); + + assertEquals("absent", resolved); + } + + @Test + public void aMessagelessFailureStillNamesItsClass() { + CapabilityProbe.attempt("optional", () -> { + throw new IllegalStateException(); + }, null); + + assertEquals("Capability probe \"optional\" is unavailable: java.lang.IllegalStateException", + messages.getFirst()); + } + + @Test + public void aSuccessfulProbeReturnsItsValueWithoutLogging() { + String resolved = CapabilityProbe.attempt("present", () -> "value", null); + + assertEquals("value", resolved); + assertTrue(messages.toString(), messages.isEmpty()); + } + + @Test + public void anActionProbeReportsWhetherItCompleted() { + assertTrue(CapabilityProbe.succeeds("action", () -> { + })); + assertTrue(messages.toString(), messages.isEmpty()); + + assertFalse(CapabilityProbe.succeeds("action", () -> { + throw new LinkageError("bad class"); + })); + assertEquals("Capability probe \"action\" is unavailable: java.lang.LinkageError: bad class", + messages.getFirst()); + } + + /** + * Swallowing an InterruptedException also swallows the interrupt, which strands whatever asked the + * thread to stop. The probe hands the flag back. + */ + @Test + public void anInterruptedProbeRestoresTheInterruptFlag() { + try { + CapabilityProbe.attempt("interruptible", () -> { + throw new InterruptedException("stop"); + }, null); + + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + @Test + public void anUnnamedCapabilityStillProducesAReadableLine() { + CapabilityProbe.attempt(null, () -> { + throw new IllegalArgumentException("nope"); + }, null); + + assertEquals("Capability probe \"unnamed\" is unavailable: java.lang.IllegalArgumentException: nope", + messages.getFirst()); + } + + private void bindCapturingPlatform() { + IrisPlatform platform = mock(IrisPlatform.class); + doAnswer(invocation -> { + levels.add(invocation.getArgument(0, LogLevel.class)); + messages.add(invocation.getArgument(1, String.class)); + return null; + }).when(platform).log(any(LogLevel.class), anyString()); + IrisPlatforms.bind(platform); + } +} diff --git a/spi/src/main/java/art/arcane/iris/spi/CapabilityProbe.java b/spi/src/main/java/art/arcane/iris/spi/CapabilityProbe.java new file mode 100644 index 000000000..6020ab8c8 --- /dev/null +++ b/spi/src/main/java/art/arcane/iris/spi/CapabilityProbe.java @@ -0,0 +1,88 @@ +/* + * Iris is a World Generator for Minecraft Bukkit 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.spi; + +/** + * Runs a lookup whose failure means "this optional API, plugin or server capability is not here" and answers + * with a fallback instead of throwing. + *

+ * This is the only sanctioned form of a quiet catch in Iris. A probe states the capability it was looking + * for, and a miss is recorded at {@link LogLevel#DEBUG} with the failure's class and message, so an operator + * running with debug on can tell a genuinely absent API apart from one that broke while being reached. Every + * other catch - world lifecycle, world replacement, runtime control, safeguard, service enable and disable, + * generation - reports through {@link IrisLogging#reportError(String, Throwable)} with the full trace. + *

+ * Internal to Iris; not a published integration surface. + */ +public final class CapabilityProbe { + private CapabilityProbe() { + } + + /** + * Resolves {@code probe}, or returns {@code unavailable} when it throws. + * + * @param capability what was being looked for, for the debug record - for example + * {@code "world#getChunkAtAsync"} + * @param probe the lookup; any {@link Throwable} it raises means the capability is absent + * @param unavailable the value that stands for an absent capability + */ + public static T attempt(String capability, Probe probe, T unavailable) { + try { + return probe.resolve(); + } catch (Throwable failure) { + recordUnavailable(capability, failure); + return unavailable; + } + } + + /** + * Runs {@code attempt} and reports whether it completed, for a probe whose only result is that it worked. + */ + public static boolean succeeds(String capability, Attempt attempt) { + try { + attempt.run(); + return true; + } catch (Throwable failure) { + recordUnavailable(capability, failure); + return false; + } + } + + private static void recordUnavailable(String capability, Throwable failure) { + if (failure instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + + String name = capability == null || capability.isBlank() ? "unnamed" : capability; + String message = failure.getMessage(); + IrisLogging.debug("Capability probe \"" + name + "\" is unavailable: " + + failure.getClass().getName() + + (message == null ? "" : ": " + message)); + } + + @FunctionalInterface + public interface Probe { + T resolve() throws Throwable; + } + + @FunctionalInterface + public interface Attempt { + void run() throws Throwable; + } +}