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:
Brian Neumann-Fopiano
2026-09-08 04:44:31 -04:00
parent 45b9ff8e11
commit f83cf71b11
34 changed files with 1267 additions and 306 deletions
@@ -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;
}
@@ -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;
}
}
@@ -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"));
}
}
@@ -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() {
}
}
}
@@ -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);
}
}