Fail-closed world generation

- Every Iris world the Bukkit resolver cannot serve gets a fail-closed generator
- CraftServer no longer receives a throw that it turns into vanilla
- A refusal banner logs the world, key, cause chain and what happens next
- The reconciler uses a throwing resolution so a broken world fails without stopping the server
- A failed enable on a server with Iris worlds keeps Iris enabled with a locked runtime
- bukkit.yml bindings in vanilla slots count as Iris worlds for that lock
- The shutdown-based vanilla fallback guard is gone
- A recorded modded bind failure rethrows instead of rebinding on every chunk call
- The first modded bind failure logs a banner
- Persistent-dimension reinject failures log a banner and repeat to operators on join
- Modded logins are refused while the configured primary Iris world is missing
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-29 05:51:10 -04:00
1 parent aa428c0383
commit 3a4da2086e
22 files changed
+1100 -151

No files matched your search

@@ -0,0 +1,73 @@
package art.arcane.iris;
import art.arcane.iris.platform.bootstrap.ServerProperties;
import art.arcane.iris.world.IrisStartupValidation;
import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import java.io.File;
import java.io.IOException;
import java.util.List;
/**
* Keeps a failed enable from handing Iris worlds to the vanilla generator.
* <p>
* Paper disables a plugin whose onEnable fails, and CraftServer builds every world bukkit.yml points at a disabled
* generator plugin with the vanilla generator. On a server with Iris worlds, Iris therefore stays enabled with its
* runtime locked: the generator resolver answers every Iris world with a fail-closed generator, which stops the
* level before it exists, and the admission listener refuses logins. It only needs IrisStartupValidation and
* logging, never a service the failed enable may not have created.
*/
final class FailedEnableLock {
private 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) {
if (!irisWorldsPresent) {
return false;
}
IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure));
List<String> notice = notice(failure);
try {
for (String line : notice) {
Iris.error(line);
}
Iris.reportError(failure);
} catch (Throwable loggingFailure) {
notice.forEach(System.err::println);
failure.addSuppressed(loggingFailure);
failure.printStackTrace(System.err);
}
return true;
}
static List<String> notice(Throwable failure) {
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.",
"Player logins are refused until this is fixed and the server restarts."
));
}
static boolean irisWorldsPresent() {
try {
return irisWorldsPresent(IrisWorldStorage.levelRoot(), ServerProperties.BUKKIT_YML);
} catch (Throwable unreadable) {
Iris.reportError("Could not inspect this server for Iris worlds; treating it as having them.", unreadable);
return true;
}
}
static boolean irisWorldsPresent(File levelRoot, File bukkitConfiguration) throws IOException {
return IrisWorldStorage.hasManagedWorldStorage(levelRoot)
|| BukkitWorldConfiguration.configuresIrisGenerator(bukkitConfiguration);
}
}
@@ -32,7 +32,6 @@ import art.arcane.iris.world.IrisStartupValidation;
import art.arcane.iris.world.IrisStartupAdmissionListener;
import art.arcane.iris.world.BukkitWorldReconciler;
import art.arcane.iris.world.IrisWorldGeneratorResolver;
import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.PendingWorldDeleteQueue;
import art.arcane.iris.world.PendingWorldReplacementManager;
import art.arcane.iris.configuration.SettingsHotloadWatch;
@@ -200,6 +199,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
private volatile boolean generatorDrainCompleted;
private volatile boolean enableFailed;
private volatile PlaceholderRegistration papiRegistration;
private volatile IrisPapiListener papiListener;
private volatile IrisPapiState papiState;
@@ -613,21 +613,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag();
}
/**
* @return false when the bootstrap was aborted (unsupported server version); the caller
* must bail out of onEnable without touching any further setup.
*/
private boolean enable() {
if (!INMS.isBound()) {
Throwable bindFailure = INMS.bindFailure();
Iris.error("Iris cannot start: " + (bindFailure == null
? "no NMS binding is available for this server version."
: bindFailure.getMessage()));
// Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
// synchronously and the loader then continues registering the half-enabled plugin.
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
return false;
}
private void enable() {
EnableTimings timings = new EnableTimings();
alreadyDrained.set(false);
postStopFinisherStarted.set(false);
@@ -768,6 +754,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
J.s(() -> {
generatorResolver.markServerRunning();
pendingWorldReplacements.captureVanillaLevelContext();
pendingWorldReplacements.verifyLoadedPublishedWorlds();
J.a(this::bstats);
@@ -788,7 +775,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
});
timings.mark("startupTasks");
timings.report();
return true;
}
private static final class EnableTimings {
@@ -910,20 +896,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public void onEnable() {
IrisPlatforms.bind(new BukkitPlatform());
enableFailed = false;
IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
pendingWorldReplacements.registerPlatformEntryListener();
boolean enabled;
try {
enabled = enable();
IrisPlatforms.bind(new BukkitPlatform());
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
pendingWorldReplacements.registerPlatformEntryListener();
if (!INMS.isBound()) {
refuseUnsupportedServer();
return;
}
enable();
} catch (Throwable failure) {
refuseVanillaFallback(failure);
throw failure;
}
if (!enabled) {
refuseVanillaFallback(null);
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) {
throw failure;
}
enableFailed = true;
return;
}
BukkitGuiHost.install();
@@ -939,54 +928,31 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
reportLockedRuntime();
}
private void refuseUnsupportedServer() {
Throwable bindFailure = INMS.bindFailure();
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())) {
enableFailed = true;
return;
}
Iris.error(failure.getMessage());
// Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
// synchronously and the loader then continues registering the half-enabled plugin.
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
}
private static void reportLockedRuntime() {
String denial = IrisStartupValidation.denialReason().orElse(null);
if (denial == null) {
return;
}
boolean managedStorage;
try {
managedStorage = IrisWorldStorage.hasManagedWorldStorage(IrisWorldStorage.levelRoot());
} catch (Throwable unavailable) {
Iris.reportError("Could not inspect Iris world storage while reporting the locked runtime.", unavailable);
managedStorage = false;
}
for (String line : RuntimeLockNotice.compose(denial, managedStorage)) {
for (String line : RuntimeLockNotice.compose(denial, FailedEnableLock.irisWorldsPresent())) {
Iris.error(line);
}
}
/**
* Stops a server whose Iris worlds would otherwise be generated by the vanilla generator.
* <p>
* A disabled Iris gets no {@code getDefaultWorldGenerator} call at all, so the server falls back to
* vanilla for every world bukkit.yml points at Iris and writes vanilla terrain into their region files.
* There is no Bukkit API that refuses a world at that point, so the server is stopped instead. This is
* damage control, not prevention: level creation runs in the same startup step that enables plugins, so
* spawn chunks of the affected worlds can still be written before the stop takes effect. The prevention
* lives in IrisBootstrap, which refuses startup before any level is created.
*/
private static void refuseVanillaFallback(Throwable failure) {
File levelRoot;
try {
levelRoot = IrisWorldStorage.levelRoot();
} catch (Throwable unavailable) {
return;
}
if (!IrisWorldStorage.hasManagedWorldStorage(levelRoot)) {
return;
}
Iris.error("Iris did not enable and this server has Iris worlds; stopping the server before they generate vanilla terrain.");
if (failure != null) {
Iris.reportError("Iris enable failed", failure);
}
try {
Bukkit.shutdown();
} catch (Throwable unavailable) {
Iris.error("Could not stop the server: " + unavailable.getClass().getSimpleName());
}
}
public BukkitDebugDump debugDump() {
return debugDump;
}
@@ -1014,7 +980,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
IrisLanguage.shutdown();
teardownPapi();
boolean serverStopping = IrisToolbelt.isServerStopping();
// 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 restartingAtStartupBoundary = startupBoundaryRestart.get();
if (restartingAtStartupBoundary) {
teardownRuntime("startup-boundary-restart", 30L);
@@ -1457,6 +1425,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return generatorResolver.resolveDefaultWorldGenerator(worldName, id);
}
public ChunkGenerator requireWorldGenerator(String worldName, String id) {
return generatorResolver.requireWorldGenerator(worldName, id);
}
public void splash() {
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
printPacks();
@@ -112,16 +112,16 @@ public final class IrisBootstrap implements PluginBootstrap {
* Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from.
* <p>
* The server enumerates levels from {@code <levelRoot>/dimensions/<namespace>/<key>} on disk, so a world
* whose folder is present is going to be loaded whatever Iris does; and CraftBukkit swallows whatever
* {@code getDefaultWorldGenerator} throws and falls back to the vanilla generator, which writes vanilla
* terrain straight into that world's region files. A pack snapshot that has gone missing therefore has
* to be found here, before any level is created, exactly as a corrupt {@code dimensions/<id>.json} is.
* 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.
* <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
* is a folder that holds no pack snapshot and no world data - {@link #quarantineWorthlessHusks} has
* already moved those out of the dimensions tree, and one that survives it (a Spigot-layout world folder,
* or a move that failed) owns nothing a vanilla generator could destroy. Both are only reported.
* or a move that failed) owns nothing to lose. Both are only reported here.
*/
static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException {
Path levelRoot = startupPaths.levelRoot();
@@ -728,7 +728,7 @@ public final class BukkitWorldReconciler {
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
String configuredWorldName = configuredWorldName(worldKey);
Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension);
ChunkGenerator generator = plugin.getDefaultWorldGenerator(configuredWorldName, dimension);
ChunkGenerator generator = plugin.requireWorldGenerator(configuredWorldName, dimension);
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
if (generator == null || irisDimension == null) {
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
@@ -18,6 +18,7 @@
package art.arcane.iris.world;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import org.bukkit.HeightMap;
import org.bukkit.Location;
import org.bukkit.World;
@@ -48,6 +49,11 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator {
+ "' remains locked: " + denialReason);
}
static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) {
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': "
+ GenerationRefusalNotice.summary(failure));
}
@Override
public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
throw refusal();
@@ -47,6 +47,7 @@ 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;
@@ -90,6 +91,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;
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
this.plugin = plugin;
@@ -480,12 +482,28 @@ public final class IrisWorldGeneratorResolver {
return fallback.get();
}
/**
* The generator CraftServer asks for. Paper turns a throw or a null here into the vanilla generator, so every
* request aimed at an Iris world gets a generator back: the Iris one, or a fail-closed one that refuses to let
* the level be created. Only the PlotSquared discovery sentinel, which is never created, gets null.
*/
@Nullable
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) {
if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) {
Iris.debug("Ignoring PlotSquared generator discovery probe");
return null;
}
try {
return requireWorldGenerator(worldName, id);
} catch (Throwable failure) {
return refuseWorld(worldName, failure);
}
}
/**
* Resolution for Iris' own loads, which report a failure per world instead of handing it to CraftServer.
*/
public ChunkGenerator requireWorldGenerator(String worldName, @Nullable String id) {
if (isGeneratorDiscoveryProbe(worldName, id)) {
Iris.debug("Generator discovery probe for loaded world " + worldName);
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
@@ -505,22 +523,56 @@ public final class IrisWorldGeneratorResolver {
return startupLock;
}
Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id);
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
String dimension = id == null || id.isEmpty() ? IrisSettings.get().getGenerator().getDefaultWorldType() : id;
Iris.debug("Generator ID: " + dimension + " requested by bukkit/plugin");
File levelRoot = IrisWorldStorage.levelRoot();
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
requireWorldKeyAvailable(worldName, worldKey);
requireOwnedWorld(worldName, levelRoot, worldKey);
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;
}
private ChunkGenerator refuseWorld(String worldName, Throwable failure) {
ChunkGenerator refused = IrisFailClosedChunkGenerator.refused(worldName, failure);
try {
return resolveFrozenWorldGenerator(worldName, id);
} catch (RuntimeException failure) {
Iris.reportError("Refusing to load configured Iris world '" + worldName
+ "' because its frozen world-local pack snapshot could not be used.", failure);
Bukkit.shutdown();
throw failure;
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);
}
return refused;
}
private static String refusalKey(String worldName) {
try {
return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName()).toString();
} catch (Throwable unresolved) {
return "unresolved key";
}
}
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