mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
Report swallowed failures
- Add art.arcane.iris.spi.CapabilityProbe so an absent optional API is recorded at debug with the failure class and message instead of a bare ignored catch - Route runtime day-time reads, clock pushes and the noon time lock through IrisLogging.reportError with the world name and the full trace - Report a level storage access that will not close, so a retained world session lock names its cause - Report a failed iris.json rewrite with the file path instead of dropping the IOException - Report Iris world registry and dimension read failures during Multiverse reconciliation instead of logging one debug line - Report a failed pack compatibility summary and a failed vanilla-slot capture with their traces - Report a superseded generation semantics shard that cannot be deleted, once, with its path - Restore the interrupt flag when the global cache trimmer join is interrupted - Replace ValueWithDiagnostics with CheckResult so a startup check carries the runtime lock reason it raises - Give every startup check an explicit advisory or critical severity and lock the runtime on any Danger result, replacing the catch that recognised the injection check by id - Lock the runtime when general.disableNMS holds a no-op binding or the NMS binding is unbound, instead of marking the runtime ready - Match incompatible plugin names against every registered plugin without regard to case - Name the JVM maximum heap in the memory check output - Print the lock reason and what it blocks when Iris enables into a locked runtime - Carry the per-check safeguard modes and diagnostic lines in /iris debugdump
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Class<?>> 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};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+7
-8
@@ -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<Chunk> future = (CompletableFuture<Chunk>) 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<Chunk> future = (CompletableFuture<Chunk>) result;
|
||||
return future;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+45
-43
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Integer>) 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<Integer>) gameRule;
|
||||
}
|
||||
|
||||
try {
|
||||
GameRule<?> byName = GameRule.getByName(name);
|
||||
if (byName != null && Integer.class.equals(byName.getType())) {
|
||||
return (GameRule<Integer>) 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<Integer>) 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<Integer>) 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<Integer>) 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<Boolean>) 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<Boolean>) gameRule;
|
||||
}
|
||||
|
||||
try {
|
||||
GameRule<?> byName = GameRule.getByName(name);
|
||||
if (byName != null && Boolean.class.equals(byName.getType())) {
|
||||
return (GameRule<Boolean>) 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<Boolean>) 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<Boolean>) 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<Boolean>) byName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Task, ValueWithDiagnostics<Mode>> 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<Task, CheckResult> results = Collections.emptyMap();
|
||||
private static Map<String, String> context = Collections.emptyMap();
|
||||
private static Map<String, List<String>> attachment = Collections.emptyMap();
|
||||
private static Mode mode = Mode.STABLE;
|
||||
@@ -27,40 +30,40 @@ public final class IrisSafeguard {
|
||||
public static void execute() {
|
||||
IrisStartupValidation.beginRuntimeValidation();
|
||||
List<Task> tasks = Tasks.getTasks();
|
||||
LinkedHashMap<Task, ValueWithDiagnostics<Mode>> resultValues = new LinkedHashMap<>(tasks.size());
|
||||
LinkedHashMap<Task, CheckResult> resultValues = new LinkedHashMap<>(tasks.size());
|
||||
LinkedHashMap<String, String> contextValues = new LinkedHashMap<>(tasks.size());
|
||||
LinkedHashMap<String, List<String>> attachmentValues = new LinkedHashMap<>(tasks.size());
|
||||
Mode currentMode = Mode.STABLE;
|
||||
String lockReason = null;
|
||||
int issueCount = 0;
|
||||
|
||||
for (Task task : tasks) {
|
||||
ValueWithDiagnostics<Mode> 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<String> 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<String, String> 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<Mode> 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() {
|
||||
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<String> 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.");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<Diagnostic> 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<Diagnostic> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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<Mode> run();
|
||||
public String failureLockReason() {
|
||||
return failureLockReason;
|
||||
}
|
||||
|
||||
public static Task of(String id, String name, Supplier<ValueWithDiagnostics<Mode>> 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<CheckResult> action) {
|
||||
return new Task(id, Mode.WARNING, null) {
|
||||
@Override
|
||||
public ValueWithDiagnostics<Mode> run() {
|
||||
public CheckResult run() {
|
||||
return action.get();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static Task of(String id, Supplier<ValueWithDiagnostics<Mode>> 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<CheckResult> action) {
|
||||
return new Task(id, Mode.UNSTABLE, throwLockReason) {
|
||||
@Override
|
||||
public ValueWithDiagnostics<Mode> run() {
|
||||
public CheckResult run() {
|
||||
return action.get();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<String> 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<String> plugins = new HashSet<>(Set.of("dynmap", "Stratos"));
|
||||
plugins.removeIf(name -> server().getPluginManager().getPlugin(name) == null);
|
||||
private static final Task INCOMPATIBILITIES = Task.advisory("incompatibilities", () -> {
|
||||
Set<String> plugins = installedIncompatiblePlugins();
|
||||
|
||||
if (plugins.isEmpty()) {
|
||||
return withDiagnostics(Mode.STABLE);
|
||||
return CheckResult.stable();
|
||||
}
|
||||
|
||||
List<Diagnostic> 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<String> 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<String> 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<String> 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<String> installedIncompatiblePlugins() {
|
||||
Set<String> 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<Diagnostic> diagnostics, Diagnostic... values) {
|
||||
@@ -222,13 +263,4 @@ public final class Tasks {
|
||||
diagnostics.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static ValueWithDiagnostics<Mode> withDiagnostics(Mode mode, Diagnostic... diagnostics) {
|
||||
return new ValueWithDiagnostics<>(mode, diagnostics);
|
||||
}
|
||||
|
||||
private static ValueWithDiagnostics<Mode> withDiagnostics(Mode mode, List<Diagnostic> diagnostics) {
|
||||
return new ValueWithDiagnostics<>(mode, diagnostics);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package art.arcane.iris.core.safeguard.task;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ValueWithDiagnostics<T> {
|
||||
private final T value;
|
||||
private final List<Diagnostic> diagnostics;
|
||||
|
||||
public ValueWithDiagnostics(T value, List<Diagnostic> 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<Diagnostic> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> 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> tasks = mockStatic(Tasks.class)) {
|
||||
tasks.when(Tasks::getTasks).thenReturn(List.of(checks));
|
||||
IrisSafeguard.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+66
@@ -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<Throwable> 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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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<Throwable> 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);
|
||||
|
||||
@@ -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<String> 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<String> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Throwable> reported = ArgumentCaptor.forClass(Throwable.class);
|
||||
verify(capturingPlatform).reportError(anyString(), reported.capture());
|
||||
assertEquals(failure, reported.getValue());
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> offenders = new ArrayList<>();
|
||||
for (Path file : protectedSources()) {
|
||||
List<String> 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<String> 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<Path> protectedSources() throws IOException {
|
||||
List<Path> 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<Path> walk = Files.walk(path)) {
|
||||
walk.filter(candidate -> candidate.toString().endsWith(".java")).forEach(files::add);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
}
|
||||
@@ -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<LogLevel> levels = new ArrayList<>();
|
||||
private final List<String> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user