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

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