mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-11 19:14:11 +00:00
Startup refusal fixes
- Bootstrap audits vanilla slots that hold an Iris world - Startup stops when such a slot has unusable history - Startup also stops when bukkit.yml no longer binds such a slot to Iris - The audit detail carries the history read cause - File system failures are named by type - Bootstrap failures print the refusal banner instead of a bare stack trace - A vanilla slot holding an Iris world is told to restore its history and keep its binding - The vanilla slot remedy adds deleting level.dat when the level was never created - A failed enable locks only for bukkit.yml worlds the server loads
This commit is contained in:
@@ -83,6 +83,6 @@ final class FailedEnableLock {
|
||||
|
||||
static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException {
|
||||
return IrisWorldStorage.hasManagedWorldStorage(levelRoot)
|
||||
|| BukkitWorldConfiguration.configuresIrisGenerator(bukkitConfiguration);
|
||||
|| BukkitWorldConfiguration.configuresLoadingIrisWorld(bukkitConfiguration, levelRoot.toPath());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import io.papermc.paper.ServerBuildInfo;
|
||||
import art.arcane.iris.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
|
||||
import art.arcane.iris.platform.bootstrap.SlimJar;
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
|
||||
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
|
||||
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
|
||||
@@ -21,6 +22,7 @@ import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -115,7 +117,9 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
* whose folder is present is going to be loaded whatever Iris does. The generator resolver answers a world
|
||||
* it cannot generate with a fail-closed generator, which stops startup at that world's own level creation,
|
||||
* after the levels before it have loaded. A pack snapshot that has gone missing is found here instead,
|
||||
* before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is.
|
||||
* before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is. An Iris world in a
|
||||
* vanilla slot whose bukkit.yml entry no longer names Iris stops startup too: CraftServer would build it
|
||||
* with the vanilla generator without ever asking Iris.
|
||||
* <p>
|
||||
* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
|
||||
* its bukkit.yml and Multiverse entries are what make restoring the folder a complete recovery. Neither
|
||||
@@ -135,6 +139,7 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
levelRoot
|
||||
);
|
||||
StringBuilder unusable = new StringBuilder();
|
||||
StringBuilder unbound = new StringBuilder();
|
||||
for (IrisWorldStorageEntry entry : entries) {
|
||||
switch (entry.state()) {
|
||||
case PRESENT -> {
|
||||
@@ -152,15 +157,29 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
.append(": ")
|
||||
.append(entry.detail() == null ? "storage is unusable" : entry.detail())
|
||||
.append(')');
|
||||
case UNBOUND -> unbound.append(unbound.isEmpty() ? "" : "; ")
|
||||
.append(entry.configuredWorldName())
|
||||
.append(" (")
|
||||
.append(entry.storagePath())
|
||||
.append("): set worlds.")
|
||||
.append(entry.configuredWorldName())
|
||||
.append(".generator to Iris:<pack> in bukkit.yml, or move the folder aside to start a new"
|
||||
+ " vanilla world there");
|
||||
}
|
||||
}
|
||||
if (unusable.isEmpty()) {
|
||||
return;
|
||||
List<String> refusals = new ArrayList<>(2);
|
||||
if (!unusable.isEmpty()) {
|
||||
refusals.add("Iris world storage is unusable and the server would generate vanilla terrain over it: "
|
||||
+ unusable + ". Restore each world folder from a backup, or move it aside; a world with no"
|
||||
+ " folder is reported and skipped, and the server starts.");
|
||||
}
|
||||
if (!unbound.isEmpty()) {
|
||||
refusals.add("Iris worlds in vanilla slots are not bound to Iris, so the server would generate vanilla"
|
||||
+ " terrain into them: " + unbound + ".");
|
||||
}
|
||||
if (!refusals.isEmpty()) {
|
||||
throw new IllegalStateException(String.join(" ", refusals));
|
||||
}
|
||||
throw new IllegalStateException("Iris world storage is unusable and the server would generate vanilla"
|
||||
+ " terrain over it: " + unusable
|
||||
+ ". Restore each world folder from a backup, or move it aside; a world with no folder is"
|
||||
+ " reported and skipped, and the server starts.");
|
||||
}
|
||||
|
||||
static void armStartupFailure(BootstrapContext context, Throwable failure) {
|
||||
@@ -175,17 +194,30 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
context.getLifecycleManager().registerEventHandler(eventType, event -> {
|
||||
throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure);
|
||||
});
|
||||
List<String> notice = startupRefusalNotice(failure);
|
||||
try {
|
||||
context.getLogger().error(
|
||||
"Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.",
|
||||
failure
|
||||
);
|
||||
for (String line : notice) {
|
||||
context.getLogger().error(line);
|
||||
}
|
||||
context.getLogger().warn("Iris bootstrap failure", failure);
|
||||
} catch (Throwable loggingFailure) {
|
||||
notice.forEach(System.err::println);
|
||||
failure.addSuppressed(loggingFailure);
|
||||
failure.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> startupRefusalNotice(Throwable failure) {
|
||||
return GenerationRefusalNotice.compose(
|
||||
"Iris stopped server startup before any world loads",
|
||||
GenerationRefusalNotice.causes(failure),
|
||||
List.of(
|
||||
"Startup stops at datapack discovery: no world loads and no chunks are written.",
|
||||
"Fix the cause, then start the server again. Paper's --safeMode hint does not apply:"
|
||||
+ " Iris worlds must not load without Iris."
|
||||
));
|
||||
}
|
||||
|
||||
private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) {
|
||||
try {
|
||||
return DefaultPackBootstrapProvisioner.provision(new BootstrapRequest(
|
||||
|
||||
+39
-2
@@ -73,6 +73,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Pack validation, dimension lookup, and the world generator / biome provider resolution that the
|
||||
@@ -622,12 +623,21 @@ public final class IrisWorldGeneratorResolver {
|
||||
}
|
||||
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
|
||||
if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
|
||||
if (dimensionRoot != null && IrisWorldStorage.holdsIrisContent(dimensionRoot.toPath())) {
|
||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey + ") is an Iris world whose"
|
||||
+ " generation history is missing from "
|
||||
+ GenerationHistoryPaths.forDimension(dimensionRoot.toPath()).generationRoot()
|
||||
+ ", so Iris cannot generate it. Restore that folder from a backup. Keep worlds." + worldName
|
||||
+ ".generator in bukkit.yml: without it the server generates vanilla terrain into this world.");
|
||||
}
|
||||
// Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run
|
||||
// until that binding is gone.
|
||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||
+ ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it."
|
||||
+ " Remove worlds." + worldName + ".generator from bukkit.yml, start the server, then run"
|
||||
+ " /iris replace " + messageKey + " type=<pack> to replace it with Iris on the next restart.");
|
||||
+ " Remove worlds." + worldName + ".generator from bukkit.yml"
|
||||
+ leftoverLevelDataRemoval(levelRoot)
|
||||
+ ", start the server, then run /iris replace " + messageKey
|
||||
+ " type=<pack> to replace it with Iris on the next restart.");
|
||||
}
|
||||
throw new IllegalStateException("'" + worldName + "' (" + messageKey
|
||||
+ ") has no Iris world storage, so Iris cannot generate it."
|
||||
@@ -635,6 +645,33 @@ public final class IrisWorldGeneratorResolver {
|
||||
+ " type=<pack>; Iris registers them with Multiverse itself.");
|
||||
}
|
||||
|
||||
/**
|
||||
* A start refused before the level's overworld existed still leaves level.dat behind, and vanilla cannot start
|
||||
* from a level.dat without world generation settings. A level that holds no chunks loses nothing to its removal.
|
||||
*/
|
||||
private static String leftoverLevelDataRemoval(File levelRoot) {
|
||||
Path level = levelRoot.toPath();
|
||||
Path overworld = level.resolve("dimensions").resolve("minecraft").resolve("overworld");
|
||||
if (Files.exists(overworld.resolve("data").resolve("minecraft").resolve("world_gen_settings.dat"),
|
||||
LinkOption.NOFOLLOW_LINKS)
|
||||
|| hasEntries(overworld.resolve("region"))
|
||||
|| hasEntries(level.resolve("region"))) {
|
||||
return "";
|
||||
}
|
||||
return " and delete " + level.resolve("level.dat") + " (this level has no chunks and was never created)";
|
||||
}
|
||||
|
||||
private static boolean hasEntries(Path directory) {
|
||||
if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(directory)) {
|
||||
return entries.findAny().isPresent();
|
||||
} catch (IOException unreadable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasGenerationStorage(File dimensionRoot) {
|
||||
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot.toPath());
|
||||
return Files.isDirectory(paths.generationRoot(), LinkOption.NOFOLLOW_LINKS)
|
||||
|
||||
@@ -183,13 +183,17 @@ public class FailedEnableLockTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception {
|
||||
public void irisWorldsAreManagedStorageOrABukkitBindingForAWorldThatLoads() throws Exception {
|
||||
File levelRoot = temporaryFolder.newFolder("level", "world");
|
||||
File bukkit = new File(temporaryFolder.getRoot(), "bukkit.yml");
|
||||
|
||||
assertFalse(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.removed_iris_world.generator", "Iris:overworld");
|
||||
yaml.save(bukkit);
|
||||
assertFalse("a leftover entry for a world that never loads", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||
|
||||
yaml.set("worlds.world.generator", "Iris:overworld");
|
||||
yaml.save(bukkit);
|
||||
assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||
|
||||
+27
@@ -8,10 +8,16 @@ import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEv
|
||||
import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType;
|
||||
import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisBootstrapFailureContractTest {
|
||||
@@ -32,6 +38,27 @@ public class IrisBootstrapFailureContractTest {
|
||||
assertSame(cause, failure.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bootstrapFailureIsReportedWithTheRefusalBanner() {
|
||||
BootstrapContext context = mock(BootstrapContext.class);
|
||||
ComponentLogger logger = mock(ComponentLogger.class);
|
||||
when(context.getLogger()).thenReturn(logger);
|
||||
when(context.getLifecycleManager()).thenReturn(new CapturingLifecycleManager());
|
||||
IllegalStateException failure = new IllegalStateException("Iris world storage is unusable",
|
||||
new IOException("Invalid generation manifest: manifest.json"));
|
||||
|
||||
IrisBootstrap.armStartupFailure(context, failure, lifecycleEventType());
|
||||
|
||||
ArgumentCaptor<String> lines = ArgumentCaptor.forClass(String.class);
|
||||
verify(logger, atLeastOnce()).error(lines.capture());
|
||||
String banner = String.join("\n", lines.getAllValues());
|
||||
assertTrue(banner, banner.startsWith("=".repeat(78)));
|
||||
assertTrue(banner, banner.contains("Cause: Iris world storage is unusable"));
|
||||
assertTrue(banner, banner.contains("Caused by: Invalid generation manifest: manifest.json"));
|
||||
assertTrue(banner, banner.contains("no chunks are written"));
|
||||
assertTrue(banner, banner.contains("--safeMode"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static LifecycleEventType<BootstrapContext, LifecycleEvent, ?> lifecycleEventType() {
|
||||
return (LifecycleEventType<BootstrapContext, LifecycleEvent, ?>) mock(LifecycleEventType.class);
|
||||
|
||||
+75
@@ -175,6 +175,81 @@ public class IrisBootstrapWorldStorageContractTest {
|
||||
assertFalse(MissingWorldStorageLog.hasWarned("world_iris_moon"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A vanilla slot that holds an Iris world is generated from its history like any Iris world, so a broken
|
||||
* history stops startup before the level exists instead of reaching the level-load refusal.
|
||||
*/
|
||||
@Test
|
||||
public void startupIsRefusedWhenABoundVanillaSlotLostItsGenerationHistory() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("broken-overworld-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/engine-data"));
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world");
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||
|
||||
assertTrue(failure.getMessage(),
|
||||
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
|
||||
.toString()));
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("generation history"));
|
||||
assertFalse(failure.getMessage(), failure.getMessage().contains("Remove worlds."));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unusableStorageNamesWhyTheHistoryCannotBeRead() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("cut-history-server").toPath();
|
||||
Path generation = serverRoot.resolve("world/dimensions/minecraft/overworld/iris/generation");
|
||||
Files.createDirectories(generation);
|
||||
Files.writeString(generation.resolve("manifest.json"), "{\"schema\":1,\"epochs\":[{\"id", StandardCharsets.UTF_8);
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot, "world");
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("Invalid generation manifest"));
|
||||
assertFalse(failure.getMessage(), failure.getMessage().contains("\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Without its bukkit.yml binding the server builds an Iris world in a vanilla slot with the vanilla generator,
|
||||
* which writes vanilla terrain into it.
|
||||
*/
|
||||
@Test
|
||||
public void startupIsRefusedWhenAnIrisWorldInAVanillaSlotLostItsBinding() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("unbound-overworld-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/iris/pack"));
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot);
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot)));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("worlds.world.generator"));
|
||||
assertTrue(failure.getMessage(),
|
||||
failure.getMessage().contains(serverRoot.toRealPath().resolve("world/dimensions/minecraft/overworld")
|
||||
.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupContinuesForVanillaSlotsWithoutIrisContent() throws Exception {
|
||||
Path serverRoot = temporaryFolder.newFolder("vanilla-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/overworld/region"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/overworld/region/r.0.0.mca"), "terrain");
|
||||
Files.createDirectories(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris"));
|
||||
Files.writeString(serverRoot.resolve("world/dimensions/minecraft/the_nether/iris/.DS_Store"), "finder");
|
||||
writeServerProperties(serverRoot);
|
||||
writeBukkitWorlds(serverRoot);
|
||||
|
||||
IrisBootstrap.requireUsableWorldStorage(startupPaths(serverRoot));
|
||||
}
|
||||
|
||||
private static BukkitStartupPaths startupPaths(Path serverRoot) throws Exception {
|
||||
return BukkitStartupPaths.resolve(serverRoot, new String[0]);
|
||||
}
|
||||
|
||||
+70
@@ -490,6 +490,76 @@ public class IrisWorldGeneratorResolverTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fix for a vanilla slot bound to Iris is removing the binding. Given for an Iris world whose history is
|
||||
* gone, that fix loads the world under the vanilla generator, which writes vanilla terrain into it.
|
||||
*/
|
||||
@Test
|
||||
public void vanillaSlotHoldingAnIrisWorldKeepsItsBindingAndPointsAtTheHistory() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("iris-in-vanilla-slot");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
File dimensionRoot = new File(levelRoot, "dimensions/minecraft/overworld");
|
||||
assertTrue(new File(dimensionRoot, "iris/engine-data").mkdirs());
|
||||
assertTrue(new File(dimensionRoot, "data/minecraft").mkdirs());
|
||||
assertTrue(new File(dimensionRoot, "data/minecraft/world_gen_settings.dat").createNewFile());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world", "overworld"));
|
||||
|
||||
assertTrue(refusal, refusal.contains("minecraft:overworld"));
|
||||
assertTrue(refusal, refusal.contains("is an Iris world"));
|
||||
assertTrue(refusal, refusal.contains(new File(dimensionRoot, "iris/generation").getPath()));
|
||||
assertFalse(refusal, refusal.contains("Remove worlds.world.generator"));
|
||||
assertFalse(refusal, refusal.contains("/iris replace"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A start refused before the overworld existed still leaves level.dat behind, and vanilla cannot start from a
|
||||
* level.dat that has no world generation settings next to it.
|
||||
*/
|
||||
@Test
|
||||
public void vanillaSlotRefusalOnANeverCreatedLevelNamesTheLeftoverLevelDat() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("never-created");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
assertTrue(levelRoot.mkdirs());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world", "overworld"));
|
||||
|
||||
assertTrue(refusal, refusal.contains("Remove worlds.world.generator"));
|
||||
assertTrue(refusal, refusal.contains(new File(levelRoot, "level.dat").getPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vanillaSlotRefusalOnAnExistingLevelLeavesLevelDatAlone() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("existing-vanilla");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
File settings = new File(levelRoot, "dimensions/minecraft/overworld/data/minecraft/world_gen_settings.dat");
|
||||
assertTrue(settings.getParentFile().mkdirs());
|
||||
assertTrue(settings.createNewFile());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world", "overworld"));
|
||||
|
||||
assertTrue(refusal, refusal.contains("Remove worlds.world.generator"));
|
||||
assertFalse(refusal, refusal.contains("level.dat"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void alreadyLoadedIrisKeyIsRefusedBeforeAnyEngineCanStart() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("duplicate-key");
|
||||
|
||||
@@ -18,11 +18,14 @@ import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class IrisWorldStorage {
|
||||
private static final String IRIS_NAMESPACE = "iris";
|
||||
private static final String DEFAULT_LEVEL_NAME = "world";
|
||||
private static final Set<String> OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
|
||||
private static final String APPLE_DOUBLE_PREFIX = "._";
|
||||
|
||||
/**
|
||||
* Server#getLevelDirectory is Paper-API-only. Once a call throws NoSuchMethodError (plain
|
||||
@@ -327,6 +330,46 @@ public final class IrisWorldStorage {
|
||||
return target.toFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the world folder's {@code iris/} entry holds anything of Iris' own, which makes the folder an Iris
|
||||
* world whatever its dimension namespace.
|
||||
* <p>
|
||||
* The desktop file managers recreate a directory to hold their own metadata while a delete is still in
|
||||
* flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder
|
||||
* that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are
|
||||
* ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on
|
||||
* the list all count as content.
|
||||
*/
|
||||
public static boolean holdsIrisContent(Path dimensionRoot) {
|
||||
Path irisRoot = Objects.requireNonNull(dimensionRoot, "dimensionRoot").resolve(IRIS_NAMESPACE);
|
||||
if (Files.isSymbolicLink(irisRoot)) {
|
||||
return true;
|
||||
}
|
||||
if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return true;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(irisRoot)) {
|
||||
return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry));
|
||||
} catch (IOException unreadable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isOperatingSystemMetadata(Path entry) {
|
||||
if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
Path name = entry.getFileName();
|
||||
if (name == null) {
|
||||
return false;
|
||||
}
|
||||
String fileName = name.toString();
|
||||
return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the level root holds at least one Iris-managed world directory. Used by the failure paths
|
||||
* that have to decide whether letting the server continue would put vanilla terrain into Iris storage.
|
||||
|
||||
@@ -4,6 +4,7 @@ import art.arcane.iris.world.IrisWorldStorage;
|
||||
import art.arcane.iris.world.WorldSlotKey;
|
||||
import art.arcane.iris.generation.runtime.IrisEngineMantle;
|
||||
import art.arcane.iris.world.history.GenerationHistoryPaths;
|
||||
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
@@ -26,11 +27,16 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class BukkitWorldConfiguration {
|
||||
private static final String DEFAULT_WORLD_CONTAINER = ".";
|
||||
private static final Object MUTATION_LOCK = new Object();
|
||||
private static final List<NamespacedKey> VANILLA_SLOTS = List.of(
|
||||
NamespacedKey.minecraft("overworld"),
|
||||
NamespacedKey.minecraft("the_nether"),
|
||||
NamespacedKey.minecraft("the_end"));
|
||||
|
||||
private BukkitWorldConfiguration() {
|
||||
}
|
||||
@@ -160,7 +166,8 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies the world storage behind every Iris entry in bukkit.yml.
|
||||
* Classifies the world storage behind every Iris entry in bukkit.yml, and behind every vanilla slot of the level
|
||||
* that holds an Iris world, bound or not.
|
||||
* <p>
|
||||
* {@link #readIrisGeneratorBindings} answers "which worlds can Iris bind", which deliberately drops
|
||||
* anything without storage. Startup needs the opposite view: a world whose folder is on disk but whose
|
||||
@@ -178,22 +185,34 @@ public final class BukkitWorldConfiguration {
|
||||
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot")
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
Path configurationPath = requiredConfigurationFile.toPath();
|
||||
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return List.of();
|
||||
}
|
||||
Path worldContainer = requiredLevelRoot.getParent();
|
||||
if (worldContainer == null) {
|
||||
throw new IOException("Selected level root has no world container: " + requiredLevelRoot);
|
||||
}
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = Files.exists(requiredConfigurationFile.toPath(), LinkOption.NOFOLLOW_LINKS)
|
||||
? load(requiredConfigurationFile)
|
||||
: new YamlConfiguration();
|
||||
List<IrisWorldStorageEntry> entries = new ArrayList<>();
|
||||
for (IrisWorldCandidate candidate : readIrisWorldCandidates(
|
||||
load(requiredConfigurationFile),
|
||||
requiredLevelName
|
||||
)) {
|
||||
for (IrisWorldCandidate candidate : readIrisWorldCandidates(configuration, requiredLevelName)) {
|
||||
entries.add(classifyStorage(worldContainer, requiredLevelRoot, candidate));
|
||||
}
|
||||
for (NamespacedKey vanillaSlot : VANILLA_SLOTS) {
|
||||
Path dimensionRoot = expectedDimensionRoot(requiredLevelRoot, vanillaSlot);
|
||||
if (!IrisWorldStorage.holdsIrisContent(dimensionRoot)) {
|
||||
continue;
|
||||
}
|
||||
String configuredName = IrisWorldStorage.configuredWorldName(vanillaSlot, requiredLevelName);
|
||||
if (configuration.get("worlds." + configuredName + ".generator") instanceof String generator
|
||||
&& isIrisGenerator(generator.trim())) {
|
||||
entries.add(classifyStorage(worldContainer, requiredLevelRoot,
|
||||
new IrisWorldCandidate(configuredName, vanillaSlot, generator.trim())));
|
||||
} else {
|
||||
entries.add(new IrisWorldStorageEntry(configuredName,
|
||||
new WorldSlotKey(vanillaSlot.getNamespace(), vanillaSlot.getKey()), dimensionRoot,
|
||||
IrisWorldStorageState.UNBOUND, "bukkit.yml does not name Iris as its generator"));
|
||||
}
|
||||
}
|
||||
return List.copyOf(entries);
|
||||
}
|
||||
}
|
||||
@@ -249,12 +268,21 @@ public final class BukkitWorldConfiguration {
|
||||
hasWorldData(resolvedRoot)
|
||||
? IrisWorldStorageState.UNUSABLE
|
||||
: IrisWorldStorageState.EMPTY,
|
||||
unusablePack.getMessage());
|
||||
describe(unusablePack));
|
||||
}
|
||||
return new IrisWorldStorageEntry(configuredName, slotKey, resolvedRoot,
|
||||
IrisWorldStorageState.PRESENT, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cause chain on one line: why a history cannot be read sits below the failure that names the world.
|
||||
*/
|
||||
private static String describe(Throwable failure) {
|
||||
return GenerationRefusalNotice.causes(failure).stream()
|
||||
.map((String cause) -> cause.lines().findFirst().orElse(cause))
|
||||
.collect(Collectors.joining("; "));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the world folder holds something a vanilla generator could overwrite: an {@code iris/}
|
||||
* directory marking it as an Iris world whose pack snapshot broke, or stored chunk data.
|
||||
@@ -395,11 +423,19 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when any bukkit.yml world names Iris as its generator, vanilla slots included. CraftServer asks a plugin
|
||||
* for a generator only for these worlds, so they are the ones a disabled Iris would hand to vanilla.
|
||||
* True when a bukkit.yml world the server loads names Iris as its generator: the level's overworld, nether or
|
||||
* end, or a world whose dimension folder exists. CraftServer asks a plugin for a generator only for worlds it
|
||||
* creates, so these are the ones a disabled Iris would hand to vanilla; an entry for a world that is never
|
||||
* created is not.
|
||||
*/
|
||||
public static boolean configuresIrisGenerator(File configurationFile) throws IOException {
|
||||
public static boolean configuresLoadingIrisWorld(File configurationFile, Path levelRoot) throws IOException {
|
||||
Path configurationPath = Objects.requireNonNull(configurationFile, "configurationFile").toPath();
|
||||
Path root = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||
Path levelName = root.getFileName();
|
||||
Path worldContainer = root.getParent();
|
||||
if (levelName == null || worldContainer == null) {
|
||||
throw new IOException("Selected level root has no world container: " + root);
|
||||
}
|
||||
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
@@ -410,7 +446,8 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
for (String worldName : worlds.getKeys(false)) {
|
||||
if (worlds.get(worldName + ".generator") instanceof String generator
|
||||
&& isIrisGenerator(generator.trim())) {
|
||||
&& isIrisGenerator(generator.trim())
|
||||
&& loadsAtStartup(worldContainer, root, levelName.toString(), worldName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -418,6 +455,21 @@ public final class BukkitWorldConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean loadsAtStartup(Path worldContainer, Path levelRoot, String levelName, String worldName) {
|
||||
if (worldName.equals(levelName)
|
||||
|| worldName.equals(levelName + "_nether")
|
||||
|| worldName.equals(levelName + "_the_end")) {
|
||||
return true;
|
||||
}
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
worldKey = IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
|
||||
} catch (IllegalArgumentException unmappable) {
|
||||
return false;
|
||||
}
|
||||
return hasDimensionDirectory(worldContainer, levelRoot, worldName, worldKey);
|
||||
}
|
||||
|
||||
private static boolean isIrisGenerator(String configuredGenerator) {
|
||||
return configuredGenerator.equalsIgnoreCase("Iris")
|
||||
|| configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5);
|
||||
@@ -766,7 +818,12 @@ public final class BukkitWorldConfiguration {
|
||||
* A world directory the server will load, whose frozen pack snapshot cannot be used. Iris cannot
|
||||
* generate it and nothing else may.
|
||||
*/
|
||||
UNUSABLE
|
||||
UNUSABLE,
|
||||
/**
|
||||
* An Iris world in a vanilla slot whose bukkit.yml entry does not name Iris, so the server would build
|
||||
* it with the vanilla generator.
|
||||
*/
|
||||
UNBOUND
|
||||
}
|
||||
|
||||
public record IrisWorldStorageEntry(
|
||||
|
||||
@@ -16,7 +16,6 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
@@ -41,8 +40,6 @@ public final class HuskWorldQuarantine {
|
||||
private static final String[] CHUNK_DATA_DIRECTORIES = {
|
||||
"region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME
|
||||
};
|
||||
private static final Set<String> OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
|
||||
private static final String APPLE_DOUBLE_PREFIX = "._";
|
||||
private static final DateTimeFormatter STAMP = DateTimeFormatter
|
||||
.ofPattern("yyyyMMdd'T'HHmmss")
|
||||
.withZone(ZoneOffset.UTC);
|
||||
@@ -117,7 +114,7 @@ public final class HuskWorldQuarantine {
|
||||
if (!isReadableDirectory(dimensionRoot)) {
|
||||
return false;
|
||||
}
|
||||
if (holdsIrisContent(dimensionRoot.resolve("iris"))) {
|
||||
if (IrisWorldStorage.holdsIrisContent(dimensionRoot)) {
|
||||
return false;
|
||||
}
|
||||
for (String directory : CHUNK_DATA_DIRECTORIES) {
|
||||
@@ -128,44 +125,6 @@ public final class HuskWorldQuarantine {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the {@code iris/} entry holds anything of Iris' own.
|
||||
* <p>
|
||||
* The desktop file managers recreate a directory to hold their own metadata while a delete is still in
|
||||
* flight - on macOS a world folder can come back holding nothing but {@code iris/.DS_Store} - and a folder
|
||||
* that holds only that is exactly as worthless as one with no {@code iris/} entry at all. Only files are
|
||||
* ever treated as metadata: a directory, a symbolic link, an unreadable entry and anything that is not on
|
||||
* the list all count as content.
|
||||
*/
|
||||
private static boolean holdsIrisContent(Path irisRoot) {
|
||||
if (Files.isSymbolicLink(irisRoot)) {
|
||||
return true;
|
||||
}
|
||||
if (!Files.exists(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
if (!Files.isDirectory(irisRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return true;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(irisRoot)) {
|
||||
return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry));
|
||||
} catch (IOException unreadable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isOperatingSystemMetadata(Path entry) {
|
||||
if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
Path name = entry.getFileName();
|
||||
if (name == null) {
|
||||
return false;
|
||||
}
|
||||
String fileName = name.toString();
|
||||
return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the path is a real directory that can actually be listed. The permission bits alone are not
|
||||
* the answer: what matters is whether Iris can see everything in it before deciding it holds nothing.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.world.safeguard;
|
||||
|
||||
import java.nio.file.FileSystemException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
@@ -31,7 +32,8 @@ public final class GenerationRefusalNotice {
|
||||
|
||||
/**
|
||||
* Every distinct message in the cause chain, outermost first. A wrapper whose message only repeats its cause
|
||||
* (CompletionException, a rethrow with the same text) adds nothing and is skipped.
|
||||
* (CompletionException, a rethrow with the same text) adds nothing and is skipped. A file system failure carries
|
||||
* only a path, so its type is named with it.
|
||||
*/
|
||||
public static List<String> causes(Throwable failure) {
|
||||
List<String> causes = new ArrayList<>();
|
||||
@@ -42,7 +44,11 @@ public final class GenerationRefusalNotice {
|
||||
String message = current.getMessage();
|
||||
boolean repeatsCause = cause != null && cause.toString().equals(message);
|
||||
if (!repeatsCause) {
|
||||
String text = message == null || message.isBlank() ? current.getClass().getSimpleName() : message.trim();
|
||||
String text = message == null || message.isBlank()
|
||||
? current.getClass().getSimpleName()
|
||||
: current instanceof FileSystemException
|
||||
? current.getClass().getSimpleName() + ": " + message.trim()
|
||||
: message.trim();
|
||||
if (causes.isEmpty() || !causes.getLast().equals(text)) {
|
||||
causes.add(text);
|
||||
}
|
||||
|
||||
+49
-7
@@ -54,11 +54,13 @@ public class BukkitWorldConfigurationTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* CraftServer only asks a plugin for a generator when bukkit.yml names it, so any world bound to Iris - a vanilla
|
||||
* slot included - is one a disabled Iris would hand to the vanilla generator.
|
||||
* CraftServer only asks a plugin for a generator when bukkit.yml names it, so a world bound to Iris - a vanilla
|
||||
* slot included - is one a disabled Iris would hand to the vanilla generator. An entry for a world the server
|
||||
* never loads is not.
|
||||
*/
|
||||
@Test
|
||||
public void detectsAnyWorldBoundToIrisIncludingVanillaSlots() throws Exception {
|
||||
public void detectsIrisBindingsOnlyForWorldsTheServerLoads() throws Exception {
|
||||
Path levelRoot = temporaryFolder.newFolder("loading", "world").toPath();
|
||||
File overworld = temporaryFolder.newFile("iris-overworld.yml");
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world.generator", "iris:overworld");
|
||||
@@ -73,11 +75,22 @@ public class BukkitWorldConfigurationTest {
|
||||
yaml.set("worlds.world.generator", "Irish:overworld");
|
||||
yaml.set("worlds.world_nether.seed", 1337L);
|
||||
yaml.save(other);
|
||||
File leftovers = temporaryFolder.newFile("leftovers.yml");
|
||||
yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.oldiris.generator", "Iris:overworld");
|
||||
yaml.set("worlds.world_iris_gone.generator", "Iris:overworld");
|
||||
yaml.save(leftovers);
|
||||
|
||||
assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(overworld));
|
||||
assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(bare));
|
||||
assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(other));
|
||||
assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(new File(temporaryFolder.getRoot(), "absent.yml")));
|
||||
assertTrue(BukkitWorldConfiguration.configuresLoadingIrisWorld(overworld, levelRoot));
|
||||
assertTrue(BukkitWorldConfiguration.configuresLoadingIrisWorld(bare, levelRoot));
|
||||
assertFalse(BukkitWorldConfiguration.configuresLoadingIrisWorld(other, levelRoot));
|
||||
assertFalse(BukkitWorldConfiguration.configuresLoadingIrisWorld(
|
||||
new File(temporaryFolder.getRoot(), "absent.yml"), levelRoot));
|
||||
assertFalse("no world folder, so the server never loads it",
|
||||
BukkitWorldConfiguration.configuresLoadingIrisWorld(leftovers, levelRoot));
|
||||
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/iris/gone"));
|
||||
assertTrue(BukkitWorldConfiguration.configuresLoadingIrisWorld(leftovers, levelRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -205,6 +218,35 @@ public class BukkitWorldConfigurationTest {
|
||||
assertTrue(entryOf(audited, "world_iris_broken").detail().contains("generation history"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A vanilla slot holding an Iris world is audited like any Iris world while bukkit.yml binds it to Iris, and is
|
||||
* reported unbound when it does not, since the server would then build it with the vanilla generator.
|
||||
*/
|
||||
@Test
|
||||
public void auditCoversVanillaSlotsThatHoldIrisWorlds() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("audit-vanilla-slots.yml");
|
||||
Path levelRoot = temporaryFolder.newFolder("audit-vanilla-slots", "world").toPath();
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/minecraft/overworld/iris/engine-data"));
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/minecraft/the_nether/iris/pack"));
|
||||
Files.createDirectories(levelRoot.resolve("dimensions/minecraft/the_end/region"));
|
||||
Files.writeString(levelRoot.resolve("dimensions/minecraft/the_end/region/r.0.0.mca"), "terrain");
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("worlds.world.generator", "Iris:overworld");
|
||||
yaml.set("worlds.world_the_end.generator", "Iris:overworld");
|
||||
yaml.save(configuration);
|
||||
|
||||
List<BukkitWorldConfiguration.IrisWorldStorageEntry> audited =
|
||||
BukkitWorldConfiguration.auditIrisWorldStorage(configuration, "world", levelRoot);
|
||||
|
||||
assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNUSABLE, stateOf(audited, "world"));
|
||||
assertEquals(BukkitWorldConfiguration.IrisWorldStorageState.UNBOUND, stateOf(audited, "world_nether"));
|
||||
assertEquals(
|
||||
levelRoot.resolve("dimensions/minecraft/the_nether").toAbsolutePath().normalize(),
|
||||
entryOf(audited, "world_nether").storagePath());
|
||||
assertFalse("a vanilla world bound to Iris is the generator resolver's refusal",
|
||||
audited.stream().anyMatch(entry -> entry.configuredWorldName().equals("world_the_end")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void auditTreatsANonDirectoryStorageSlotAsAnOrphanTheServerNeverLoads() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("audit-file-slot.yml");
|
||||
|
||||
@@ -3,6 +3,7 @@ package art.arcane.iris.world.safeguard;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletionException;
|
||||
|
||||
@@ -33,6 +34,20 @@ public class GenerationRefusalNoticeTest {
|
||||
new IllegalStateException("history drifted", root)));
|
||||
}
|
||||
|
||||
/**
|
||||
* A file system failure's message is only the path, which says nothing about what went wrong with it.
|
||||
*/
|
||||
@Test
|
||||
public void fileSystemFailuresAreNamedByTheirType() {
|
||||
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
|
||||
new NoSuchFileException("/srv/moon/iris/generation/epochs/a/epoch.json"));
|
||||
|
||||
assertEquals(List.of(
|
||||
"Iris generation history is unusable at /srv/moon.",
|
||||
"NoSuchFileException: /srv/moon/iris/generation/epochs/a/epoch.json"
|
||||
), GenerationRefusalNotice.causes(failure));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aCyclicCauseChainTerminates() {
|
||||
IllegalStateException first = new IllegalStateException("first");
|
||||
|
||||
Reference in New Issue
Block a user