mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-25 13:30:08 +00:00
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:
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();
|
||||
|
||||
+1
-1
@@ -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 + "\".");
|
||||
|
||||
+6
@@ -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();
|
||||
|
||||
+60
-8
@@ -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
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.world.IrisStartupAdmissionListener;
|
||||
import art.arcane.iris.world.IrisStartupValidation;
|
||||
import art.arcane.iris.world.IrisWorldGeneratorResolver;
|
||||
import com.destroystokyo.paper.profile.PlayerProfile;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.bukkit.generator.WorldInfo;
|
||||
import org.junit.After;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.InetAddress;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
/**
|
||||
* Paper disables a plugin whose onEnable throws, and CraftServer then builds every world bukkit.yml points at a
|
||||
* disabled generator plugin with the vanilla generator. A failed enable on a server with Iris worlds therefore has
|
||||
* to leave Iris enabled and locked instead.
|
||||
*/
|
||||
public class FailedEnableLockTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@After
|
||||
public void disableValidation() {
|
||||
IrisStartupValidation.disable();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableFailureWithIrisWorldsLocksTheRuntimeInsteadOfDisablingIris() throws Exception {
|
||||
IrisStartupValidation.begin();
|
||||
IllegalStateException failure = new IllegalStateException("service graph exploded");
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
assertTrue(FailedEnableLock.engage(failure, true));
|
||||
|
||||
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||
assertTrue(denial, denial.contains("service graph exploded"));
|
||||
|
||||
ChunkGenerator generator = new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world", "overworld");
|
||||
assertNotNull("a null generator is the vanilla generator", generator);
|
||||
IllegalStateException refusal = assertThrows(IllegalStateException.class,
|
||||
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("service graph exploded"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
|
||||
AsyncPlayerPreLoginEvent login = new AsyncPlayerPreLoginEvent(
|
||||
"LockedTest", InetAddress.getLoopbackAddress(), UUID.randomUUID(), false, mock(PlayerProfile.class));
|
||||
new IrisStartupAdmissionListener().onAsyncPlayerPreLogin(login);
|
||||
assertEquals(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, login.getLoginResult());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enableFailureWithoutIrisWorldsLeavesIrisToBeDisabled() {
|
||||
IrisStartupValidation.begin();
|
||||
|
||||
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false));
|
||||
}
|
||||
|
||||
String denial = IrisStartupValidation.denialReason().orElseThrow();
|
||||
assertFalse(denial, denial.contains("service graph exploded"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void noticeSaysIrisStaysLockedAndWhatThatBlocks() {
|
||||
String notice = String.join("\n", FailedEnableLock.notice(
|
||||
new IllegalStateException("outer", new IllegalArgumentException("inner"))));
|
||||
|
||||
assertTrue(notice, notice.contains("Iris failed to enable"));
|
||||
assertTrue(notice, notice.contains("outer"));
|
||||
assertTrue(notice, notice.contains("inner"));
|
||||
assertTrue(notice, notice.contains("vanilla"));
|
||||
assertTrue(notice, notice.contains("startup stops"));
|
||||
assertTrue(notice, notice.contains("logins"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void irisWorldsAreManagedStorageOrAnyBukkitBinding() 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.world.generator", "Iris:overworld");
|
||||
yaml.save(bukkit);
|
||||
assertTrue("an Iris overworld lives in a vanilla slot", FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||
|
||||
assertTrue(bukkit.delete());
|
||||
assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs());
|
||||
assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package art.arcane.iris.world;
|
||||
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.bukkit.generator.WorldInfo;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Paper 26.3 asks a plugin generator for its biome provider in MinecraftServer.createLevel and
|
||||
* CraftServer.createWorld before the level exists, outside any catch. A throw there is the only thing that stops a
|
||||
* broken Iris world from being created at all, so every fail-closed generator must keep throwing from it.
|
||||
*/
|
||||
public class IrisFailClosedChunkGeneratorTest {
|
||||
@Test
|
||||
public void everyFailClosedGeneratorRefusesTheBiomeProviderBeforeTheLevelExists() {
|
||||
List<ChunkGenerator> generators = List.of(
|
||||
IrisFailClosedChunkGenerator.discoveryProbe("world"),
|
||||
IrisFailClosedChunkGenerator.startupLock("world", "locked for a test"),
|
||||
IrisFailClosedChunkGenerator.refused("world", new IllegalStateException("history drifted")));
|
||||
|
||||
for (ChunkGenerator generator : generators) {
|
||||
IllegalStateException refusal = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("'world'"));
|
||||
assertThrows(IllegalStateException.class, () -> generator.getDefaultPopulators(mock(World.class)));
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> generator.getFixedSpawnLocation(mock(World.class), new Random()));
|
||||
assertFalse(generator.shouldGenerateNoise());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusalCarriesTheWholeCauseChain() {
|
||||
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
|
||||
new IOException("Historical generated registry definition changed"));
|
||||
|
||||
IllegalStateException refusal = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisFailClosedChunkGenerator.refused("world_iris_moon", failure)
|
||||
.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon"));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable"));
|
||||
assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed"));
|
||||
}
|
||||
}
|
||||
+122
-56
@@ -24,6 +24,7 @@ import org.bukkit.generator.WorldInfo;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -256,18 +257,12 @@ public class IrisWorldGeneratorResolverTest {
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("CheckingPlotSquaredGenerator"));
|
||||
assertTrue(refusal, refusal.contains("CheckingPlotSquaredGenerator"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
@@ -382,32 +377,26 @@ public class IrisWorldGeneratorResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void externalCreateWithoutIrisStorageThrowsWithoutShutdown() throws Exception {
|
||||
public void externalCreateWithoutIrisStorageFailsClosedWithoutShutdown() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("external-create");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
assertTrue(levelRoot.mkdirs());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("mvtest", "overworld"));
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("mvtest", "overworld"));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("mvtest"));
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
|
||||
assertTrue(refusal, refusal.contains("mvtest"));
|
||||
assertTrue(refusal, refusal.contains("/iris create"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownedIrisWorldWithUnusableSnapshotStillStopsTheServer() throws Exception {
|
||||
public void ownedIrisWorldWithUnusableSnapshotFailsClosedWithoutShutdown() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("owned-broken");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
assertTrue(levelRoot.mkdirs());
|
||||
@@ -415,42 +404,103 @@ public class IrisWorldGeneratorResolverTest {
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world_iris_moon", "overworld"));
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world_iris_moon", "overworld"));
|
||||
|
||||
bukkit.verify(Bukkit::shutdown);
|
||||
assertTrue(refusal, refusal.contains("world_iris_moon"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tester's failure: a world whose generation history no longer matches the live registries. CraftServer
|
||||
* turns a throw here into the vanilla generator, so the history failure has to come back as a generator that
|
||||
* refuses to create the level.
|
||||
*/
|
||||
@Test
|
||||
public void unusableGenerationHistoryFailsClosedWithoutShutdown() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("history-broken").getCanonicalFile();
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
File dimensionRoot = new File(levelRoot, "dimensions/iris/moon");
|
||||
assertTrue(new File(dimensionRoot, "iris/generation").mkdirs());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||
MockedStatic<IrisWorlds> worlds = mockStatic(IrisWorlds.class)) {
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L);
|
||||
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("moon", "overworld"));
|
||||
|
||||
assertTrue(refusal, refusal.contains("'moon'"));
|
||||
assertTrue(refusal, refusal.contains("generation history is unusable"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vanillaDimensionSlotWithoutFrozenPackIsNotOwned() throws Exception {
|
||||
public void internalLoadsGetTheResolutionFailureWithoutShutdown() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("internal-load").getCanonicalFile();
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
File dimensionRoot = new File(levelRoot, "dimensions/iris/moon");
|
||||
assertTrue(new File(dimensionRoot, "iris/generation").mkdirs());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||
MockedStatic<IrisWorlds> worlds = mockStatic(IrisWorlds.class)) {
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
worlds.when(() -> IrisWorlds.readBukkitWorldSeed("moon")).thenReturn(1337L);
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null).requireWorldGenerator("moon", "overworld"));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("generation history is unusable"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
iris.verify(() -> Iris.error(any(String.class), any(Object[].class)), never());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusalNoticeNamesTheWorldTheCauseChainAndWhatHappensNext() {
|
||||
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
|
||||
new IOException("Historical generated registry definition changed for iris:biomes/abc"));
|
||||
|
||||
String startup = String.join("\n", IrisWorldGeneratorResolver.refusalNotice(
|
||||
"world_iris_moon", "iris:moon", failure, false));
|
||||
String runtime = String.join("\n", IrisWorldGeneratorResolver.refusalNotice(
|
||||
"world_iris_moon", "iris:moon", failure, true));
|
||||
|
||||
for (String notice : List.of(startup, runtime)) {
|
||||
assertTrue(notice, notice.contains("world_iris_moon"));
|
||||
assertTrue(notice, notice.contains("iris:moon"));
|
||||
assertTrue(notice, notice.contains("Iris generation history is unusable at /srv/moon."));
|
||||
assertTrue(notice, notice.contains("Historical generated registry definition changed for iris:biomes/abc"));
|
||||
assertTrue(notice, notice.contains("no chunks"));
|
||||
}
|
||||
assertTrue(startup, startup.contains("startup stops"));
|
||||
assertTrue(runtime, runtime.contains("does not load"));
|
||||
assertFalse(runtime, runtime.contains("startup stops"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception {
|
||||
File worldContainer = temporaryFolder.newFolder("vanilla-slot");
|
||||
File levelRoot = new File(worldContainer, "world");
|
||||
assertTrue(new File(levelRoot, "dimensions/minecraft/the_nether").mkdirs());
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world_nether", "overworld"));
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("world_nether", "overworld"));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("minecraft:the_nether"));
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
|
||||
assertTrue(refusal, refusal.contains("minecraft:the_nether"));
|
||||
assertTrue(refusal, refusal.contains("/iris create"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
@@ -468,23 +518,39 @@ public class IrisWorldGeneratorResolverTest {
|
||||
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(loaded));
|
||||
mockServer(bukkit, worldContainer, levelRoot, List.of(loaded));
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("irisworld", "overworld"));
|
||||
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||
.resolveDefaultWorldGenerator("irisworld", "overworld"));
|
||||
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("iris:irisworld"));
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_irisworld"));
|
||||
assertTrue(refusal, refusal.contains("iris:irisworld"));
|
||||
assertTrue(refusal, refusal.contains("world_iris_irisworld"));
|
||||
bukkit.verify(Bukkit::shutdown, never());
|
||||
}
|
||||
}
|
||||
|
||||
private static void mockServer(MockedStatic<Bukkit> bukkit, File worldContainer, File levelRoot, List<World> worlds) {
|
||||
Server server = mock(Server.class);
|
||||
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
|
||||
bukkit.when(Bukkit::getServer).thenReturn(server);
|
||||
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
|
||||
bukkit.when(Bukkit::getWorlds).thenReturn(worlds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves through CraftServer's fallback and proves the result is Iris' refusal rather than vanilla: Paper
|
||||
* calls getDefaultBiomeProvider before the level exists, and that throw is what stops the level being created.
|
||||
*/
|
||||
private static String assertFailsClosed(Supplier<ChunkGenerator> resolution) {
|
||||
ChunkGenerator vanillaFallback = mock(ChunkGenerator.class);
|
||||
ChunkGenerator selected = craftBukkitGeneratorOrFallback(resolution, vanillaFallback);
|
||||
assertNotSame(vanillaFallback, selected);
|
||||
IllegalStateException refusal = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> selected.getDefaultBiomeProvider(mock(WorldInfo.class)));
|
||||
return refusal.getMessage();
|
||||
}
|
||||
|
||||
private static void writeValidPack(Path packRoot) throws Exception {
|
||||
Files.createDirectories(packRoot.resolve("dimensions"));
|
||||
Files.createDirectories(packRoot.resolve("regions"));
|
||||
|
||||
Reference in new issue
Block a user