Fail-closed review fixes

- Modded logins are refused only when this save's dimension registry lists the primary world
- Otherwise Iris warns once and leaves routing idle
- An unreadable dimension registry counts as expecting the primary world
- A vanilla slot bound to Iris without Iris storage points at the bukkit.yml entry and /iris replace
- The refusal consequence comes from whether startup worlds exist instead of the first tick
- The first WorldLoadEvent or a hotload marks startup worlds as existing
- A failed enable on a hotload says the server keeps running
- Reconciler resolution failures report the refusal banner
- Operators get a join notice for such failures until the world loads
- The fail-closed generator is built even when the cause message cannot be read
- Tests drive Iris.onEnable for the lock, the unsupported-server lock and the rethrow
- The failed-enable teardown decision lives in FailedEnableLock with a test
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-29 06:30:28 -04:00
1 parent 3a4da2086e
commit eefcbc40ef
13 files changed
+644 -84

No files matched your search

@@ -26,12 +26,12 @@ final class FailedEnableLock {
/**
* @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before
*/
static boolean engage(Throwable failure, boolean irisWorldsPresent) {
static boolean engage(Throwable failure, boolean irisWorldsPresent, boolean startupWorldsCreated) {
if (!irisWorldsPresent) {
return false;
}
IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure));
List<String> notice = notice(failure);
List<String> notice = notice(failure, startupWorldsCreated);
try {
for (String line : notice) {
Iris.error(line);
@@ -45,14 +45,29 @@ final class FailedEnableLock {
return true;
}
static List<String> notice(Throwable failure) {
/**
* Deferring teardown to the server stop quiesces through the NMS binding and waits for generators Paper closes.
* A failed enable may have neither, so it tears down at once even while the server stops.
*/
static boolean defersTeardownToServerStop(boolean enableFailed, boolean serverStopping) {
return !enableFailed && serverStopping;
}
/**
* @param startupWorldsCreated true when Iris enabled on a running server (a hotload), whose startup worlds
* already exist, so nothing stops
*/
static List<String> notice(Throwable failure, boolean startupWorldsCreated) {
return GenerationRefusalNotice.compose(
"Iris failed to enable",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris stays enabled with a locked runtime so no Iris world falls back to vanilla generation.",
"Every Iris world refuses to generate: server startup stops before the first one loads,"
+ " and no chunks are written.",
startupWorldsCreated
? "Every Iris world that is not loaded yet refuses to load; the server keeps running"
+ " and no chunks are written for them."
: "Every Iris world refuses to generate: server startup stops before the first one"
+ " loads, and no chunks are written.",
"Player logins are refused until this is fixed and the server restarts."
));
}
@@ -34,6 +34,7 @@ import art.arcane.iris.world.BukkitWorldReconciler;
import art.arcane.iris.world.IrisWorldGeneratorResolver;
import art.arcane.iris.world.PendingWorldDeleteQueue;
import art.arcane.iris.world.PendingWorldReplacementManager;
import art.arcane.iris.world.WorldRefusalReporter;
import art.arcane.iris.configuration.SettingsHotloadWatch;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService;
@@ -754,7 +755,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
J.s(() -> {
generatorResolver.markServerRunning();
pendingWorldReplacements.captureVanillaLevelContext();
pendingWorldReplacements.verifyLoadedPublishedWorlds();
J.a(this::bstats);
@@ -900,6 +900,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
try {
generatorResolver.refusals().attach(this);
IrisPlatforms.bind(new BukkitPlatform());
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
pendingWorldReplacements.registerPlatformEntryListener();
@@ -909,7 +910,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
enable();
} catch (Throwable failure) {
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) {
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
generatorResolver.refusals().startupWorldsCreated())) {
throw failure;
}
enableFailed = true;
@@ -933,7 +935,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
? "no NMS binding is available for this server version."
: bindFailure.getMessage()), bindFailure);
if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) {
if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
generatorResolver.refusals().startupWorldsCreated())) {
enableFailed = true;
return;
}
@@ -980,13 +983,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
IrisLanguage.shutdown();
teardownPapi();
// A failed enable left no generator for Paper's shutdown to close, so it tears down the way a failed
// enable always did instead of deferring to the post-stop finisher.
boolean serverStopping = !enableFailed && IrisToolbelt.isServerStopping();
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping());
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
if (restartingAtStartupBoundary) {
teardownRuntime("startup-boundary-restart", 30L);
} else if (serverStopping) {
} else if (deferToServerStop) {
quiesceRuntimeForServerShutdown("onDisable");
startPostStopFinisher();
} else {
@@ -1004,7 +1005,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
// super.onDisable() cancels plugin tasks and unregisters every listener.
super.onDisable();
if (!serverStopping || restartingAtStartupBoundary) {
if (!deferToServerStop || restartingAtStartupBoundary) {
finishTerminalCleanup();
}
}
@@ -1429,6 +1430,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return generatorResolver.requireWorldGenerator(worldName, id);
}
public WorldRefusalReporter worldRefusals() {
return generatorResolver.refusals();
}
public void splash() {
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
printPacks();
@@ -722,19 +722,30 @@ public final class BukkitWorldReconciler {
return WorldIdentity.resolve(worldKey);
}
/**
* A world Iris cannot resolve is refused like one CraftServer asked for, with the banner and operator notice,
* then fails this load only.
*/
@Override
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
String configuredWorldName = worldKey.toString();
ChunkGenerator generator;
IrisDimension irisDimension;
try {
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
String configuredWorldName = configuredWorldName(worldKey);
Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension);
ChunkGenerator generator = plugin.requireWorldGenerator(configuredWorldName, dimension);
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
configuredWorldName = configuredWorldName(worldKey);
Iris.info("Loading World: %s | Generator: %s", IrisWorldStorage.logicalName(worldKey), dimension);
generator = plugin.requireWorldGenerator(configuredWorldName, dimension);
irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
if (generator == null || irisDimension == null) {
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
}
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "...");
} catch (Throwable refusal) {
plugin.worldRefusals().report(configuredWorldName, worldKey, refusal);
return CompletableFuture.failedFuture(refusal);
}
try {
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + IrisWorldStorage.logicalName(worldKey)
+ " using Iris:" + dimension + "...");
WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey)
.generator(generator)
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
@@ -49,9 +49,18 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator {
+ "' remains locked: " + denialReason);
}
/**
* Built on the way back to CraftServer, which turns any throw into the vanilla generator, so a cause that cannot
* be summarized is named by its type instead.
*/
static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) {
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': "
+ GenerationRefusalNotice.summary(failure));
String reason;
try {
reason = GenerationRefusalNotice.summary(failure);
} catch (Throwable unreadable) {
reason = failure.getClass().getName();
}
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + reason);
}
@Override
@@ -47,7 +47,6 @@ import art.arcane.iris.world.history.GenerationHistoryPaths;
import art.arcane.iris.world.history.GenerationPackFingerprint;
import art.arcane.iris.world.history.GenerationRegistryContract;
import art.arcane.iris.world.history.GenerationRegistryContractFactory;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.iris.platform.generation.BukkitChunkGenerator;
import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin;
import art.arcane.iris.world.task.J;
@@ -91,7 +90,7 @@ public final class IrisWorldGeneratorResolver {
private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean();
private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean();
private final Map<Path, PendingSnapshot> pendingSnapshots = new ConcurrentHashMap<>();
private volatile boolean serverRunning;
private final WorldRefusalReporter refusals = new WorldRefusalReporter();
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
this.plugin = plugin;
@@ -533,48 +532,30 @@ public final class IrisWorldGeneratorResolver {
return resolveFrozenWorldGenerator(worldName, dimension);
}
/**
* Startup worlds are created before the first server tick; anything asked for after it is a runtime load.
*/
public void markServerRunning() {
serverRunning = true;
public WorldRefusalReporter refusals() {
return refusals;
}
private ChunkGenerator refuseWorld(String worldName, Throwable failure) {
ChunkGenerator refused = IrisFailClosedChunkGenerator.refused(worldName, failure);
refusals.report(worldName, refusalKey(worldName), failure);
try {
for (String line : refusalNotice(worldName, refusalKey(worldName), failure, serverRunning)) {
Iris.error(line);
}
Iris.reportError(failure);
} catch (Throwable loggingFailure) {
failure.addSuppressed(loggingFailure);
failure.printStackTrace(System.err);
System.err.println("[Iris] Could not log the refusal of '" + worldName + "': "
+ loggingFailure.getClass().getName());
}
return refused;
return IrisFailClosedChunkGenerator.refused(worldName, failure);
}
private static String refusalKey(String worldName) {
@Nullable
private static NamespacedKey refusalKey(String worldName) {
try {
return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName()).toString();
return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
} catch (Throwable unresolved) {
return "unresolved key";
return null;
}
}
static List<String> refusalNotice(String worldName, String worldKey, Throwable failure, boolean serverRunning) {
return GenerationRefusalNotice.compose(
"Iris refused to generate world '" + worldName + "' (" + worldKey + ")",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris does not generate this world and does not let vanilla or any other generator"
+ " write it; no chunks are written.",
serverRunning
? "The world does not load; the server keeps running. Fix the cause, then load it again."
: "Server startup stops before this world loads. Fix the cause, then start the server again."
));
}
@Nullable
private static ChunkGenerator startupLockedGenerator(String worldName) {
Optional<String> startupDenial = IrisStartupValidation.denialReason();
@@ -640,6 +621,14 @@ public final class IrisWorldGeneratorResolver {
return;
}
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
// 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.");
}
throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ ") has no Iris world storage, so Iris cannot generate it."
+ " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
@@ -0,0 +1,122 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.world;
import art.arcane.iris.Iris;
import art.arcane.iris.localization.C;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentSkipListMap;
/**
* Reports Iris worlds that refused to generate: a console banner that says what the refusal does to the server, and
* an operator notice on join for as long as the world stays unloaded, since the banner scrolls away.
*/
public final class WorldRefusalReporter implements Listener {
private static final String UNRESOLVED_KEY = "unresolved key";
private final Map<String, String> operatorNotices = new ConcurrentSkipListMap<>();
private volatile boolean startupWorldsCreated;
/**
* Iris enabled on a running server (a hotload) finds the startup worlds already loaded.
*/
public void attach(Plugin plugin) {
if (!Bukkit.getWorlds().isEmpty()) {
startupWorldsCreated = true;
}
Bukkit.getPluginManager().registerEvents(this, plugin);
}
/**
* Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
* A generator asked for after that belongs to a runtime create, which fails only its caller.
*/
public boolean startupWorldsCreated() {
return startupWorldsCreated;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) {
startupWorldsCreated = true;
try {
operatorNotices.remove(WorldIdentity.key(event.getWorld()).toString());
} catch (RuntimeException unkeyed) {
Iris.debug("Loaded world " + event.getWorld().getName() + " has no key to clear a refusal notice by");
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerJoin(PlayerJoinEvent event) {
if (operatorNotices.isEmpty()) {
return;
}
Player player = event.getPlayer();
if (!player.isOp() && !player.hasPermission("iris.all")) {
return;
}
for (String notice : operatorNotices.values()) {
player.sendMessage(C.RED + notice);
}
}
/**
* Never throws: the caller is handing CraftServer a fail-closed generator or failing a load, and a reporting
* failure must not turn either into something else.
*/
public void report(String worldName, @Nullable NamespacedKey worldKey, Throwable failure) {
String key = worldKey == null ? UNRESOLVED_KEY : worldKey.toString();
try {
for (String line : notice(worldName, key, failure, startupWorldsCreated)) {
Iris.error(line);
}
operatorNotices.put(worldKey == null ? worldName : key, "Iris world '" + worldName + "' (" + key
+ ") did not load and refuses to generate: " + GenerationRefusalNotice.summary(failure));
} catch (Throwable reportingFailure) {
System.err.println("[Iris] Iris refused to generate world '" + worldName + "' (" + key
+ "); the refusal could not be reported: " + reportingFailure.getClass().getName());
}
}
static List<String> notice(String worldName, String worldKey, Throwable failure, boolean startupWorldsCreated) {
return GenerationRefusalNotice.compose(
"Iris refused to generate world '" + worldName + "' (" + worldKey + ")",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris does not generate this world and does not let vanilla or any other generator"
+ " write it; no chunks are written.",
startupWorldsCreated
? "The world does not load; the server keeps running. Fix the cause, then load it again."
: "Server startup stops before this world loads. Fix the cause, then start the server again."
));
}
}