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:
Brian Neumann-Fopiano committed 2026-09-29 08:44:07 -04:00
1 parent b1fbede364
commit c7cca23155
13 files changed
+448 -81

No files matched your search

@@ -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);
}
@@ -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");