mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-26 05:50:39 +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.IrisStartupAdmissionListener;
|
||||||
import art.arcane.iris.world.BukkitWorldReconciler;
|
import art.arcane.iris.world.BukkitWorldReconciler;
|
||||||
import art.arcane.iris.world.IrisWorldGeneratorResolver;
|
import art.arcane.iris.world.IrisWorldGeneratorResolver;
|
||||||
import art.arcane.iris.world.IrisWorldStorage;
|
|
||||||
import art.arcane.iris.world.PendingWorldDeleteQueue;
|
import art.arcane.iris.world.PendingWorldDeleteQueue;
|
||||||
import art.arcane.iris.world.PendingWorldReplacementManager;
|
import art.arcane.iris.world.PendingWorldReplacementManager;
|
||||||
import art.arcane.iris.configuration.SettingsHotloadWatch;
|
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 terminalCleanupCompleted = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
|
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
|
||||||
private volatile boolean generatorDrainCompleted;
|
private volatile boolean generatorDrainCompleted;
|
||||||
|
private volatile boolean enableFailed;
|
||||||
private volatile PlaceholderRegistration papiRegistration;
|
private volatile PlaceholderRegistration papiRegistration;
|
||||||
private volatile IrisPapiListener papiListener;
|
private volatile IrisPapiListener papiListener;
|
||||||
private volatile IrisPapiState papiState;
|
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 plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private void enable() {
|
||||||
* @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;
|
|
||||||
}
|
|
||||||
EnableTimings timings = new EnableTimings();
|
EnableTimings timings = new EnableTimings();
|
||||||
alreadyDrained.set(false);
|
alreadyDrained.set(false);
|
||||||
postStopFinisherStarted.set(false);
|
postStopFinisherStarted.set(false);
|
||||||
@@ -768,6 +754,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
J.s(() -> {
|
J.s(() -> {
|
||||||
|
generatorResolver.markServerRunning();
|
||||||
pendingWorldReplacements.captureVanillaLevelContext();
|
pendingWorldReplacements.captureVanillaLevelContext();
|
||||||
pendingWorldReplacements.verifyLoadedPublishedWorlds();
|
pendingWorldReplacements.verifyLoadedPublishedWorlds();
|
||||||
J.a(this::bstats);
|
J.a(this::bstats);
|
||||||
@@ -788,7 +775,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
});
|
});
|
||||||
timings.mark("startupTasks");
|
timings.mark("startupTasks");
|
||||||
timings.report();
|
timings.report();
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class EnableTimings {
|
private static final class EnableTimings {
|
||||||
@@ -910,20 +896,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
IrisPlatforms.bind(new BukkitPlatform());
|
enableFailed = false;
|
||||||
IrisStartupValidation.begin();
|
IrisStartupValidation.begin();
|
||||||
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
||||||
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
|
||||||
pendingWorldReplacements.registerPlatformEntryListener();
|
|
||||||
boolean enabled;
|
|
||||||
try {
|
try {
|
||||||
enabled = enable();
|
IrisPlatforms.bind(new BukkitPlatform());
|
||||||
|
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
||||||
|
pendingWorldReplacements.registerPlatformEntryListener();
|
||||||
|
if (!INMS.isBound()) {
|
||||||
|
refuseUnsupportedServer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enable();
|
||||||
} catch (Throwable failure) {
|
} catch (Throwable failure) {
|
||||||
refuseVanillaFallback(failure);
|
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) {
|
||||||
throw failure;
|
throw failure;
|
||||||
}
|
}
|
||||||
if (!enabled) {
|
enableFailed = true;
|
||||||
refuseVanillaFallback(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
BukkitGuiHost.install();
|
BukkitGuiHost.install();
|
||||||
@@ -939,54 +928,31 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
reportLockedRuntime();
|
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() {
|
private static void reportLockedRuntime() {
|
||||||
String denial = IrisStartupValidation.denialReason().orElse(null);
|
String denial = IrisStartupValidation.denialReason().orElse(null);
|
||||||
if (denial == null) {
|
if (denial == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean managedStorage;
|
for (String line : RuntimeLockNotice.compose(denial, FailedEnableLock.irisWorldsPresent())) {
|
||||||
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)) {
|
|
||||||
Iris.error(line);
|
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() {
|
public BukkitDebugDump debugDump() {
|
||||||
return debugDump;
|
return debugDump;
|
||||||
}
|
}
|
||||||
@@ -1014,7 +980,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
}
|
}
|
||||||
IrisLanguage.shutdown();
|
IrisLanguage.shutdown();
|
||||||
teardownPapi();
|
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();
|
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
|
||||||
if (restartingAtStartupBoundary) {
|
if (restartingAtStartupBoundary) {
|
||||||
teardownRuntime("startup-boundary-restart", 30L);
|
teardownRuntime("startup-boundary-restart", 30L);
|
||||||
@@ -1457,6 +1425,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
return generatorResolver.resolveDefaultWorldGenerator(worldName, id);
|
return generatorResolver.resolveDefaultWorldGenerator(worldName, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ChunkGenerator requireWorldGenerator(String worldName, String id) {
|
||||||
|
return generatorResolver.requireWorldGenerator(worldName, id);
|
||||||
|
}
|
||||||
|
|
||||||
public void splash() {
|
public void splash() {
|
||||||
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
|
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
|
||||||
printPacks();
|
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.
|
* Stops startup when a world the server is about to load carries Iris storage Iris cannot generate from.
|
||||||
* <p>
|
* <p>
|
||||||
* The server enumerates levels from {@code <levelRoot>/dimensions/<namespace>/<key>} on disk, so a world
|
* 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
|
* whose folder is present is going to be loaded whatever Iris does. The generator resolver answers a world
|
||||||
* {@code getDefaultWorldGenerator} throws and falls back to the vanilla generator, which writes vanilla
|
* it cannot generate with a fail-closed generator, which stops startup at that world's own level creation,
|
||||||
* terrain straight into that world's region files. A pack snapshot that has gone missing therefore has
|
* after the levels before it have loaded. A pack snapshot that has gone missing is found here instead,
|
||||||
* to be found here, 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.
|
||||||
* <p>
|
* <p>
|
||||||
* A world with no folder at all is not a startup failure: nothing enumerates it, nothing loads it, and
|
* 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
|
* 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
|
* 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,
|
* 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 {
|
static void requireUsableWorldStorage(BukkitStartupPaths startupPaths) throws IOException {
|
||||||
Path levelRoot = startupPaths.levelRoot();
|
Path levelRoot = startupPaths.levelRoot();
|
||||||
|
|||||||
+1
-1
@@ -728,7 +728,7 @@ public final class BukkitWorldReconciler {
|
|||||||
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
|
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
|
||||||
String configuredWorldName = configuredWorldName(worldKey);
|
String configuredWorldName = configuredWorldName(worldKey);
|
||||||
Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension);
|
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);
|
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
||||||
if (generator == null || irisDimension == null) {
|
if (generator == null || irisDimension == null) {
|
||||||
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
|
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
|
||||||
|
|||||||
+6
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
package art.arcane.iris.world;
|
package art.arcane.iris.world;
|
||||||
|
|
||||||
|
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||||
import org.bukkit.HeightMap;
|
import org.bukkit.HeightMap;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
@@ -48,6 +49,11 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator {
|
|||||||
+ "' remains locked: " + denialReason);
|
+ "' remains locked: " + denialReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) {
|
||||||
|
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': "
|
||||||
|
+ GenerationRefusalNotice.summary(failure));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
|
public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull ChunkData chunkData) {
|
||||||
throw refusal();
|
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.GenerationPackFingerprint;
|
||||||
import art.arcane.iris.world.history.GenerationRegistryContract;
|
import art.arcane.iris.world.history.GenerationRegistryContract;
|
||||||
import art.arcane.iris.world.history.GenerationRegistryContractFactory;
|
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.generation.BukkitChunkGenerator;
|
||||||
import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin;
|
import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin;
|
||||||
import art.arcane.iris.world.task.J;
|
import art.arcane.iris.world.task.J;
|
||||||
@@ -90,6 +91,7 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean();
|
private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean();
|
||||||
private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean();
|
private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean();
|
||||||
private final Map<Path, PendingSnapshot> pendingSnapshots = new ConcurrentHashMap<>();
|
private final Map<Path, PendingSnapshot> pendingSnapshots = new ConcurrentHashMap<>();
|
||||||
|
private volatile boolean serverRunning;
|
||||||
|
|
||||||
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
|
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
@@ -480,12 +482,28 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
return fallback.get();
|
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
|
@Nullable
|
||||||
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) {
|
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) {
|
||||||
if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) {
|
if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) {
|
||||||
Iris.debug("Ignoring PlotSquared generator discovery probe");
|
Iris.debug("Ignoring PlotSquared generator discovery probe");
|
||||||
return null;
|
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)) {
|
if (isGeneratorDiscoveryProbe(worldName, id)) {
|
||||||
Iris.debug("Generator discovery probe for loaded world " + worldName);
|
Iris.debug("Generator discovery probe for loaded world " + worldName);
|
||||||
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
|
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
|
||||||
@@ -505,22 +523,56 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
return startupLock;
|
return startupLock;
|
||||||
}
|
}
|
||||||
Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id);
|
Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id);
|
||||||
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
|
String dimension = id == null || id.isEmpty() ? IrisSettings.get().getGenerator().getDefaultWorldType() : id;
|
||||||
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
|
Iris.debug("Generator ID: " + dimension + " requested by bukkit/plugin");
|
||||||
|
|
||||||
File levelRoot = IrisWorldStorage.levelRoot();
|
File levelRoot = IrisWorldStorage.levelRoot();
|
||||||
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
|
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName(), levelRoot);
|
||||||
requireWorldKeyAvailable(worldName, worldKey);
|
requireWorldKeyAvailable(worldName, worldKey);
|
||||||
requireOwnedWorld(worldName, levelRoot, 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 {
|
try {
|
||||||
return resolveFrozenWorldGenerator(worldName, id);
|
for (String line : refusalNotice(worldName, refusalKey(worldName), failure, serverRunning)) {
|
||||||
} catch (RuntimeException failure) {
|
Iris.error(line);
|
||||||
Iris.reportError("Refusing to load configured Iris world '" + worldName
|
}
|
||||||
+ "' because its frozen world-local pack snapshot could not be used.", failure);
|
Iris.reportError(failure);
|
||||||
Bukkit.shutdown();
|
} catch (Throwable loggingFailure) {
|
||||||
throw failure;
|
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
|
@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 org.mockito.MockedStatic;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -256,18 +257,12 @@ public class IrisWorldGeneratorResolverTest {
|
|||||||
|
|
||||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||||
Server server = mock(Server.class);
|
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||||
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());
|
|
||||||
|
|
||||||
IllegalStateException failure = assertThrows(
|
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||||
IllegalStateException.class,
|
.resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
|
||||||
() -> new IrisWorldGeneratorResolver(null)
|
|
||||||
.resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
|
|
||||||
|
|
||||||
assertTrue(failure.getMessage(), failure.getMessage().contains("CheckingPlotSquaredGenerator"));
|
assertTrue(refusal, refusal.contains("CheckingPlotSquaredGenerator"));
|
||||||
bukkit.verify(Bukkit::shutdown, never());
|
bukkit.verify(Bukkit::shutdown, never());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,32 +377,26 @@ public class IrisWorldGeneratorResolverTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void externalCreateWithoutIrisStorageThrowsWithoutShutdown() throws Exception {
|
public void externalCreateWithoutIrisStorageFailsClosedWithoutShutdown() throws Exception {
|
||||||
File worldContainer = temporaryFolder.newFolder("external-create");
|
File worldContainer = temporaryFolder.newFolder("external-create");
|
||||||
File levelRoot = new File(worldContainer, "world");
|
File levelRoot = new File(worldContainer, "world");
|
||||||
assertTrue(levelRoot.mkdirs());
|
assertTrue(levelRoot.mkdirs());
|
||||||
|
|
||||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||||
Server server = mock(Server.class);
|
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||||
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());
|
|
||||||
|
|
||||||
IllegalStateException failure = assertThrows(
|
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||||
IllegalStateException.class,
|
.resolveDefaultWorldGenerator("mvtest", "overworld"));
|
||||||
() -> new IrisWorldGeneratorResolver(null)
|
|
||||||
.resolveDefaultWorldGenerator("mvtest", "overworld"));
|
|
||||||
|
|
||||||
assertTrue(failure.getMessage(), failure.getMessage().contains("mvtest"));
|
assertTrue(refusal, refusal.contains("mvtest"));
|
||||||
assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
|
assertTrue(refusal, refusal.contains("/iris create"));
|
||||||
bukkit.verify(Bukkit::shutdown, never());
|
bukkit.verify(Bukkit::shutdown, never());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void ownedIrisWorldWithUnusableSnapshotStillStopsTheServer() throws Exception {
|
public void ownedIrisWorldWithUnusableSnapshotFailsClosedWithoutShutdown() throws Exception {
|
||||||
File worldContainer = temporaryFolder.newFolder("owned-broken");
|
File worldContainer = temporaryFolder.newFolder("owned-broken");
|
||||||
File levelRoot = new File(worldContainer, "world");
|
File levelRoot = new File(worldContainer, "world");
|
||||||
assertTrue(levelRoot.mkdirs());
|
assertTrue(levelRoot.mkdirs());
|
||||||
@@ -415,42 +404,103 @@ public class IrisWorldGeneratorResolverTest {
|
|||||||
|
|
||||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||||
Server server = mock(Server.class);
|
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||||
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());
|
|
||||||
|
|
||||||
assertThrows(
|
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||||
IllegalStateException.class,
|
.resolveDefaultWorldGenerator("world_iris_moon", "overworld"));
|
||||||
() -> 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
|
@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 worldContainer = temporaryFolder.newFolder("vanilla-slot");
|
||||||
File levelRoot = new File(worldContainer, "world");
|
File levelRoot = new File(worldContainer, "world");
|
||||||
assertTrue(new File(levelRoot, "dimensions/minecraft/the_nether").mkdirs());
|
assertTrue(new File(levelRoot, "dimensions/minecraft/the_nether").mkdirs());
|
||||||
|
|
||||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||||
Server server = mock(Server.class);
|
mockServer(bukkit, worldContainer, levelRoot, List.of());
|
||||||
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());
|
|
||||||
|
|
||||||
IllegalStateException failure = assertThrows(
|
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||||
IllegalStateException.class,
|
.resolveDefaultWorldGenerator("world_nether", "overworld"));
|
||||||
() -> new IrisWorldGeneratorResolver(null)
|
|
||||||
.resolveDefaultWorldGenerator("world_nether", "overworld"));
|
|
||||||
|
|
||||||
assertTrue(failure.getMessage(), failure.getMessage().contains("minecraft:the_nether"));
|
assertTrue(refusal, refusal.contains("minecraft:the_nether"));
|
||||||
assertTrue(failure.getMessage(), failure.getMessage().contains("/iris create"));
|
assertTrue(refusal, refusal.contains("/iris create"));
|
||||||
bukkit.verify(Bukkit::shutdown, never());
|
bukkit.verify(Bukkit::shutdown, never());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,23 +518,39 @@ public class IrisWorldGeneratorResolverTest {
|
|||||||
|
|
||||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||||
Server server = mock(Server.class);
|
mockServer(bukkit, worldContainer, levelRoot, List.of(loaded));
|
||||||
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));
|
|
||||||
|
|
||||||
IllegalStateException failure = assertThrows(
|
String refusal = assertFailsClosed(() -> new IrisWorldGeneratorResolver(null)
|
||||||
IllegalStateException.class,
|
.resolveDefaultWorldGenerator("irisworld", "overworld"));
|
||||||
() -> new IrisWorldGeneratorResolver(null)
|
|
||||||
.resolveDefaultWorldGenerator("irisworld", "overworld"));
|
|
||||||
|
|
||||||
assertTrue(failure.getMessage(), failure.getMessage().contains("iris:irisworld"));
|
assertTrue(refusal, refusal.contains("iris:irisworld"));
|
||||||
assertTrue(failure.getMessage(), failure.getMessage().contains("world_iris_irisworld"));
|
assertTrue(refusal, refusal.contains("world_iris_irisworld"));
|
||||||
bukkit.verify(Bukkit::shutdown, never());
|
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 {
|
private static void writeValidPack(Path packRoot) throws Exception {
|
||||||
Files.createDirectories(packRoot.resolve("dimensions"));
|
Files.createDirectories(packRoot.resolve("dimensions"));
|
||||||
Files.createDirectories(packRoot.resolve("regions"));
|
Files.createDirectories(packRoot.resolve("regions"));
|
||||||
|
|||||||
+25
-2
@@ -59,6 +59,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext;
|
|||||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||||
|
|
||||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
|
||||||
|
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||||
import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord;
|
import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord;
|
||||||
|
|
||||||
import art.arcane.iris.structure.nativegen.IrisStructurePolicy;
|
import art.arcane.iris.structure.nativegen.IrisStructurePolicy;
|
||||||
@@ -454,11 +455,14 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
|||||||
|
|
||||||
private Engine bindEngine(NativeWorld level) {
|
private Engine bindEngine(NativeWorld level) {
|
||||||
requireBindingAllowed();
|
requireBindingAllowed();
|
||||||
|
// A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the
|
||||||
|
// history verification on every chunk call and fail the same way.
|
||||||
|
engineBinding.throwIfFailed(dimensionKey);
|
||||||
try {
|
try {
|
||||||
requireGlobalStructureGeneration(
|
requireGlobalStructureGeneration(
|
||||||
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
|
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
|
||||||
} catch (RuntimeException error) {
|
} catch (RuntimeException error) {
|
||||||
engineBinding.fail(error);
|
recordBindFailure(level, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
// Cache the owning level so hot paths never scan the level map to find themselves.
|
// Cache the owning level so hot paths never scan the level map to find themselves.
|
||||||
@@ -500,7 +504,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
|||||||
engineBinding.complete(created);
|
engineBinding.complete(created);
|
||||||
return created;
|
return created;
|
||||||
} catch (Throwable error) {
|
} catch (Throwable error) {
|
||||||
engineBinding.fail(error);
|
recordBindFailure(level, error);
|
||||||
if (error instanceof RuntimeException runtimeException) {
|
if (error instanceof RuntimeException runtimeException) {
|
||||||
throw runtimeException;
|
throw runtimeException;
|
||||||
}
|
}
|
||||||
@@ -512,6 +516,25 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void recordBindFailure(NativeWorld level, Throwable error) {
|
||||||
|
engineBinding.fail(error);
|
||||||
|
for (String line : bindFailureNotice(level.name(), dimensionKey, error)) {
|
||||||
|
ModdedIrisLog.error(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> bindFailureNotice(String levelName, String dimensionKey, Throwable failure) {
|
||||||
|
return GenerationRefusalNotice.compose(
|
||||||
|
"Iris refused to generate " + levelName + " (generator " + dimensionKey + ")",
|
||||||
|
GenerationRefusalNotice.causes(failure),
|
||||||
|
List.of(
|
||||||
|
"Iris does not generate this level and does not let vanilla or any other generator"
|
||||||
|
+ " write it; no chunks are written.",
|
||||||
|
"A startup level stops the server; a runtime dimension stays unloaded."
|
||||||
|
+ " Fix the cause, then start the server again."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
private void requireCompletedShutdown(Engine current) {
|
private void requireCompletedShutdown(Engine current) {
|
||||||
if (current == null || !current.isClosing() || current.isClosed()) {
|
if (current == null || !current.isClosing() || current.isClosed()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+55
@@ -18,12 +18,15 @@
|
|||||||
|
|
||||||
package art.arcane.iris.modded;
|
package art.arcane.iris.modded;
|
||||||
|
|
||||||
|
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
|
||||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
|
||||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -36,6 +39,7 @@ public final class ModdedPrimaryWorldRouter {
|
|||||||
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet();
|
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet();
|
||||||
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
|
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
|
||||||
private static int tickCounter = 0;
|
private static int tickCounter = 0;
|
||||||
|
private static volatile String loginRefusal;
|
||||||
|
|
||||||
private ModdedPrimaryWorldRouter() {
|
private ModdedPrimaryWorldRouter() {
|
||||||
}
|
}
|
||||||
@@ -43,6 +47,7 @@ public final class ModdedPrimaryWorldRouter {
|
|||||||
public static void clear() {
|
public static void clear() {
|
||||||
routed.clear();
|
routed.clear();
|
||||||
inFlight.clear();
|
inFlight.clear();
|
||||||
|
loginRefusal = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +61,20 @@ public final class ModdedPrimaryWorldRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects a joining player while the configured primary world is missing.
|
||||||
|
*
|
||||||
|
* @return true when the player was refused
|
||||||
|
*/
|
||||||
|
public static boolean refuseIfUnavailable(NativeProtocolPlayer player) {
|
||||||
|
String refusal = loginRefusal;
|
||||||
|
if (refusal == null || player == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
player.disconnect(refusal);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public static void tick(NativeModdedServer server) {
|
public static void tick(NativeModdedServer server) {
|
||||||
if (server == null) {
|
if (server == null) {
|
||||||
return;
|
return;
|
||||||
@@ -68,17 +87,23 @@ public final class ModdedPrimaryWorldRouter {
|
|||||||
|
|
||||||
ModdedModConfig config = ModdedModConfig.get();
|
ModdedModConfig config = ModdedModConfig.get();
|
||||||
if (!config.routePlayersToPrimaryWorld()) {
|
if (!config.routePlayersToPrimaryWorld()) {
|
||||||
|
loginRefusal = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String primary = config.primaryWorld();
|
String primary = config.primaryWorld();
|
||||||
if (primary.isBlank()) {
|
if (primary.isBlank()) {
|
||||||
|
loginRefusal = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
NativeWorld target = ModdedDimensionManager.level(server, primary);
|
NativeWorld target = ModdedDimensionManager.level(server, primary);
|
||||||
if (target == null) {
|
if (target == null) {
|
||||||
|
if (ModdedStartup.dimensionsRestored()) {
|
||||||
|
refuseLogins(server, primary);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
loginRefusal = null;
|
||||||
NativeWorld overworld = server.overworld();
|
NativeWorld overworld = server.overworld();
|
||||||
if (NativeDimensionRuntime.sameWorld(target, overworld)) {
|
if (NativeDimensionRuntime.sameWorld(target, overworld)) {
|
||||||
return;
|
return;
|
||||||
@@ -115,4 +140,34 @@ public final class ModdedPrimaryWorldRouter {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Players left in the overworld would generate the terrain the primary Iris world was configured to replace, so a
|
||||||
|
* primary world that did not load refuses every player instead.
|
||||||
|
*/
|
||||||
|
private static void refuseLogins(NativeModdedServer server, String primary) {
|
||||||
|
String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players."
|
||||||
|
+ " An operator has to check the server console.";
|
||||||
|
if (loginRefusal == null) {
|
||||||
|
for (String line : GenerationRefusalNotice.compose(
|
||||||
|
"Iris refused player logins: primary world '" + primary + "' is not loaded",
|
||||||
|
List.of("routePlayersToPrimaryWorld sends players to '" + primary
|
||||||
|
+ "', which did not load at startup."),
|
||||||
|
List.of(
|
||||||
|
"Players are disconnected instead of playing in the overworld, so no chunks are"
|
||||||
|
+ " written there.",
|
||||||
|
"Fix the dimension (see the Iris errors above) or clear primaryWorld in"
|
||||||
|
+ " config/irisworldgen/modded.json, then start the server again."
|
||||||
|
))) {
|
||||||
|
ModdedIrisLog.error(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loginRefusal = refusal;
|
||||||
|
List<NativeProtocolPlayer> online = new ArrayList<>();
|
||||||
|
// Disconnecting removes the player from the list being walked, so collect first.
|
||||||
|
server.forEachPlayer(online::add);
|
||||||
|
for (NativeProtocolPlayer player : online) {
|
||||||
|
player.disconnect(refusal);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -123,11 +123,11 @@ public final class ModdedProtocolHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void onPlayerJoin(NativeProtocolPlayer player) {
|
public static void onPlayerJoin(NativeProtocolPlayer player) {
|
||||||
if (player == null) {
|
if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String sessionId = sessionId(player);
|
String sessionId = sessionId(player);
|
||||||
ModdedStartup.warnPackFailuresTo(player);
|
ModdedStartup.warnStartupFailuresTo(player);
|
||||||
IrisSessionRegistry current = registry;
|
IrisSessionRegistry current = registry;
|
||||||
ModdedProtocolTransport currentTransport = transport;
|
ModdedProtocolTransport currentTransport = transport;
|
||||||
if (current == null || currentTransport == null) {
|
if (current == null || currentTransport == null) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import art.arcane.iris.pack.PackValidationResult;
|
|||||||
import art.arcane.iris.pack.PackValidator;
|
import art.arcane.iris.pack.PackValidator;
|
||||||
import art.arcane.iris.modded.command.ModdedPackCommands;
|
import art.arcane.iris.modded.command.ModdedPackCommands;
|
||||||
import art.arcane.iris.spi.IrisPlatforms;
|
import art.arcane.iris.spi.IrisPlatforms;
|
||||||
|
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
|
||||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ import java.nio.file.Path;
|
|||||||
import java.nio.file.attribute.BasicFileAttributes;
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ public final class ModdedStartup {
|
|||||||
private static final int COMPAT_BOOT_KEY_CAP = 3;
|
private static final int COMPAT_BOOT_KEY_CAP = 3;
|
||||||
private static final AtomicBoolean PREPARED = new AtomicBoolean(false);
|
private static final AtomicBoolean PREPARED = new AtomicBoolean(false);
|
||||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||||
|
private static final List<String> DIMENSION_FAILURES = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
private ModdedStartup() {
|
private ModdedStartup() {
|
||||||
}
|
}
|
||||||
@@ -50,6 +53,7 @@ public final class ModdedStartup {
|
|||||||
public static void reset() {
|
public static void reset() {
|
||||||
PREPARED.set(false);
|
PREPARED.set(false);
|
||||||
STARTED.set(false);
|
STARTED.set(false);
|
||||||
|
DIMENSION_FAILURES.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void prepareForStartup() {
|
public static void prepareForStartup() {
|
||||||
@@ -208,7 +212,7 @@ public final class ModdedStartup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void reinjectPersistentDimensions(NativeModdedServer server) {
|
static void reinjectPersistentDimensions(NativeModdedServer server) {
|
||||||
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
|
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
|
||||||
ModdedDimensionRegistryStore.loadForStartup(server);
|
ModdedDimensionRegistryStore.loadForStartup(server);
|
||||||
if (dimensions.isEmpty()) {
|
if (dimensions.isEmpty()) {
|
||||||
@@ -233,7 +237,7 @@ public final class ModdedStartup {
|
|||||||
index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(),
|
index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(),
|
||||||
System.currentTimeMillis() - dimensionStartedAt);
|
System.currentTimeMillis() - dimensionStartedAt);
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e);
|
reportDimensionFailure(dimension, e);
|
||||||
if (e instanceof OutOfMemoryError outOfMemory) {
|
if (e instanceof OutOfMemoryError outOfMemory) {
|
||||||
throw outOfMemory;
|
throw outOfMemory;
|
||||||
}
|
}
|
||||||
@@ -243,6 +247,25 @@ public final class ModdedStartup {
|
|||||||
injected, dimensions.size(), System.currentTimeMillis() - startedAt);
|
injected, dimensions.size(), System.currentTimeMillis() - startedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void reportDimensionFailure(ModdedDimensionRegistryStore.PersistentDimension dimension, Throwable failure) {
|
||||||
|
List<String> notice = GenerationRefusalNotice.compose(
|
||||||
|
"Iris refused to load dimension '" + dimension.id() + "' (pack=" + dimension.pack()
|
||||||
|
+ " dim=" + dimension.dimension() + " seed=" + dimension.seed() + ")",
|
||||||
|
GenerationRefusalNotice.causes(failure),
|
||||||
|
List.of(
|
||||||
|
"Iris does not generate this dimension and does not let vanilla or any other generator"
|
||||||
|
+ " write it; no chunks are written.",
|
||||||
|
"It stays unloaded. Fix the cause, then start the server again."
|
||||||
|
));
|
||||||
|
for (String line : notice) {
|
||||||
|
ModdedIrisLog.error(line);
|
||||||
|
}
|
||||||
|
ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})",
|
||||||
|
dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), failure);
|
||||||
|
DIMENSION_FAILURES.add("Iris dimension '" + dimension.id() + "' did not load and refuses to generate: "
|
||||||
|
+ GenerationRefusalNotice.summary(failure));
|
||||||
|
}
|
||||||
|
|
||||||
private static long newestModificationMillis(Path root) {
|
private static long newestModificationMillis(Path root) {
|
||||||
long newest = 0L;
|
long newest = 0L;
|
||||||
try (Stream<Path> walk = Files.walk(root)) {
|
try (Stream<Path> walk = Files.walk(root)) {
|
||||||
@@ -261,13 +284,24 @@ public final class ModdedStartup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SP-6: a pack excluded by validation is otherwise only visible in the console. Tell the operators who
|
* True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime
|
||||||
* can actually act on it when they join.
|
* dimension that is not loaded yet is still on its way.
|
||||||
*/
|
*/
|
||||||
public static void warnPackFailuresTo(NativeProtocolPlayer player) {
|
public static boolean dimensionsRestored() {
|
||||||
|
return STARTED.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SP-6: a pack excluded by validation or a persistent dimension that did not load is otherwise only visible in
|
||||||
|
* the console. Tell the operators who can actually act on it when they join.
|
||||||
|
*/
|
||||||
|
public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
|
||||||
if (player == null || !player.isGameMaster()) {
|
if (player == null || !player.isGameMaster()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
for (String failure : DIMENSION_FAILURES) {
|
||||||
|
player.sendMessage(failure);
|
||||||
|
}
|
||||||
for (Map.Entry<String, PackValidationResult> entry : PackValidationRegistry.snapshot().entrySet()) {
|
for (Map.Entry<String, PackValidationResult> entry : PackValidationRegistry.snapshot().entrySet()) {
|
||||||
PackValidationResult result = entry.getValue();
|
PackValidationResult result = entry.getValue();
|
||||||
if (result == null || result.isLoadable()) {
|
if (result == null || result.isLoadable()) {
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
package art.arcane.iris.modded;
|
||||||
|
|
||||||
|
import art.arcane.iris.generation.runtime.Engine;
|
||||||
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenerator;
|
||||||
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||||
|
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||||
|
import net.minecraft.SharedConstants;
|
||||||
|
import net.minecraft.server.Bootstrap;
|
||||||
|
import net.minecraft.server.level.ServerChunkCache;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||||
|
import org.junit.BeforeClass;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertSame;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A level whose Iris generator cannot bind must crash its own creation, never fall through to another generator,
|
||||||
|
* and every later chunk call must fail fast on the recorded failure instead of re-running the whole bind
|
||||||
|
* (history verification included) on every worker thread.
|
||||||
|
*/
|
||||||
|
public class ModdedBindFailureTest {
|
||||||
|
@BeforeClass
|
||||||
|
public static void bootstrap() {
|
||||||
|
SharedConstants.tryDetectVersion();
|
||||||
|
Bootstrap.bootStrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aRecordedBindFailureIsReportedOnceAndRethrownWithoutBindingAgain() throws Exception {
|
||||||
|
IrisModdedChunkGenerator generator = generator("overworld:overworld");
|
||||||
|
NativeWorld level = level(generator, "minecraft:overworld");
|
||||||
|
field("boundLevel").set(generator, level);
|
||||||
|
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||||
|
when(server.generateStructures()).thenReturn(false);
|
||||||
|
|
||||||
|
try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class);
|
||||||
|
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||||
|
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
|
||||||
|
|
||||||
|
IllegalStateException recorded = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
|
||||||
|
IllegalStateException second = assertThrows(IllegalStateException.class, generator::engine);
|
||||||
|
IllegalStateException third = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
|
||||||
|
|
||||||
|
assertSame(recorded, second.getCause());
|
||||||
|
assertSame(recorded, third.getCause());
|
||||||
|
servers.verify(() -> NativeModdedServer.forWorld(level), times(1));
|
||||||
|
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
|
||||||
|
&& line.contains("generate-structures=false"))), times(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void bindFailureNoticeNamesTheLevelTheCauseAndThatNothingGenerates() {
|
||||||
|
String notice = String.join("\n", IrisModdedChunkGenerator.bindFailureNotice(
|
||||||
|
"minecraft:overworld",
|
||||||
|
"overworld:overworld",
|
||||||
|
new IllegalStateException("Iris generation history is unusable for 'minecraft:overworld'",
|
||||||
|
new IOException("Historical generated registry definition changed"))));
|
||||||
|
|
||||||
|
assertTrue(notice, notice.contains("minecraft:overworld"));
|
||||||
|
assertTrue(notice, notice.contains("overworld:overworld"));
|
||||||
|
assertTrue(notice, notice.contains("generation history is unusable"));
|
||||||
|
assertTrue(notice, notice.contains("registry definition changed"));
|
||||||
|
assertTrue(notice, notice.contains("no chunks"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IrisModdedChunkGenerator generator(String dimensionKey) throws ReflectiveOperationException {
|
||||||
|
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS);
|
||||||
|
field("nativeGenerator").set(generator, mock(NativeModdedChunkGenerator.class, CALLS_REAL_METHODS));
|
||||||
|
field("engineBinding").set(generator, new ModdedEngineBinding<Engine>(1L, TimeUnit.SECONDS));
|
||||||
|
field("dimensionKey").set(generator, dimensionKey);
|
||||||
|
return generator;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NativeWorld level(IrisModdedChunkGenerator generator, String name) throws ReflectiveOperationException {
|
||||||
|
NativeWorld level = mock(NativeWorld.class);
|
||||||
|
ServerLevel handle = mock(ServerLevel.class);
|
||||||
|
ServerChunkCache chunkSource = mock(ServerChunkCache.class);
|
||||||
|
when(level.nativeHandle()).thenReturn(handle);
|
||||||
|
when(level.name()).thenReturn(name);
|
||||||
|
when(handle.getChunkSource()).thenReturn(chunkSource);
|
||||||
|
when(chunkSource.getGenerator()).thenReturn((ChunkGenerator) field("nativeGenerator").get(generator));
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Field field(String name) throws NoSuchFieldException {
|
||||||
|
Field field = IrisModdedChunkGenerator.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package art.arcane.iris.modded;
|
||||||
|
|
||||||
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||||
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||||
|
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A configured primary Iris world that did not load used to be skipped silently, leaving every player in the
|
||||||
|
* overworld generating terrain Iris was configured to replace.
|
||||||
|
*/
|
||||||
|
public class ModdedPrimaryWorldRouterTest {
|
||||||
|
private static final int TICK_INTERVAL = 20;
|
||||||
|
private static final String PRIMARY = "iris:primary";
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void clearRouter() {
|
||||||
|
ModdedPrimaryWorldRouter.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aMissingPrimaryWorldRefusesPlayersOnceStartupHasRestoredDimensions() {
|
||||||
|
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||||
|
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
|
||||||
|
onlinePlayers(server, online);
|
||||||
|
ModdedModConfig routed = config(true, PRIMARY);
|
||||||
|
|
||||||
|
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||||
|
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||||
|
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||||
|
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||||
|
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||||
|
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
|
||||||
|
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
|
||||||
|
|
||||||
|
evaluate(server);
|
||||||
|
evaluate(server);
|
||||||
|
|
||||||
|
verify(online, times(2)).disconnect(contains(PRIMARY));
|
||||||
|
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
|
||||||
|
&& line.contains(PRIMARY))), times(1));
|
||||||
|
NativeProtocolPlayer joining = mock(NativeProtocolPlayer.class);
|
||||||
|
assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(joining));
|
||||||
|
verify(joining).disconnect(contains(PRIMARY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is
|
||||||
|
* missing then is still loading, not broken.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void aPrimaryWorldIsNotJudgedBeforeStartupRestoresDimensions() {
|
||||||
|
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||||
|
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
|
||||||
|
onlinePlayers(server, online);
|
||||||
|
ModdedModConfig routed = config(true, PRIMARY);
|
||||||
|
|
||||||
|
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||||
|
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||||
|
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||||
|
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||||
|
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||||
|
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
|
||||||
|
startup.when(ModdedStartup::dimensionsRestored).thenReturn(false);
|
||||||
|
|
||||||
|
evaluate(server);
|
||||||
|
|
||||||
|
verify(online, never()).disconnect(anyString());
|
||||||
|
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void refusalLiftsOnceThePrimaryWorldIsLoadedOrUnconfigured() {
|
||||||
|
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||||
|
NativeWorld overworld = mock(NativeWorld.class);
|
||||||
|
when(server.overworld()).thenReturn(overworld);
|
||||||
|
onlinePlayers(server, mock(NativeProtocolPlayer.class));
|
||||||
|
ModdedModConfig routed = config(true, PRIMARY);
|
||||||
|
ModdedModConfig unconfigured = config(true, "");
|
||||||
|
|
||||||
|
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||||
|
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||||
|
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
|
||||||
|
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||||
|
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
|
||||||
|
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||||
|
evaluate(server);
|
||||||
|
assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||||
|
|
||||||
|
configs.when(ModdedModConfig::get).thenReturn(unconfigured);
|
||||||
|
evaluate(server);
|
||||||
|
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||||
|
|
||||||
|
configs.when(ModdedModConfig::get).thenReturn(routed);
|
||||||
|
evaluate(server);
|
||||||
|
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(overworld);
|
||||||
|
evaluate(server);
|
||||||
|
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void evaluate(NativeModdedServer server) {
|
||||||
|
for (int tick = 0; tick < TICK_INTERVAL; tick++) {
|
||||||
|
ModdedPrimaryWorldRouter.tick(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void onlinePlayers(NativeModdedServer server, NativeProtocolPlayer player) {
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
invocation.getArgument(0, Consumer.class).accept(player);
|
||||||
|
return null;
|
||||||
|
}).when(server).forEachPlayer(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ModdedModConfig config(boolean route, String primary) {
|
||||||
|
ModdedModConfig config = mock(ModdedModConfig.class);
|
||||||
|
when(config.routePlayersToPrimaryWorld()).thenReturn(route);
|
||||||
|
when(config.primaryWorld()).thenReturn(primary);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package art.arcane.iris.modded;
|
||||||
|
|
||||||
|
import art.arcane.iris.pack.PackValidationRegistry;
|
||||||
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||||
|
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A persistent dimension that cannot be re-injected is skipped, which already writes no chunks. The skip used to be
|
||||||
|
* one console line; it has to be a banner and has to reach the operators who can fix it.
|
||||||
|
*/
|
||||||
|
public class ModdedStartupReinjectFailureTest {
|
||||||
|
@After
|
||||||
|
public void resetStartup() {
|
||||||
|
ModdedStartup.reset();
|
||||||
|
PackValidationRegistry.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aDimensionThatFailsToReinjectIsBanneredAndReportedToOperatorsOnJoin() {
|
||||||
|
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||||
|
ModdedDimensionRegistryStore.PersistentDimension moon =
|
||||||
|
new ModdedDimensionRegistryStore.PersistentDimension("iris:moon", "overworld", "overworld", 1337L);
|
||||||
|
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable for 'iris:moon'");
|
||||||
|
|
||||||
|
try (MockedStatic<ModdedDimensionRegistryStore> store = mockStatic(ModdedDimensionRegistryStore.class);
|
||||||
|
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||||
|
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||||
|
store.when(() -> ModdedDimensionRegistryStore.loadForStartup(server)).thenReturn(List.of(moon));
|
||||||
|
manager.when(() -> ModdedDimensionManager.restorePersistent(server, "iris:moon", "overworld", "overworld", 1337L))
|
||||||
|
.thenThrow(failure);
|
||||||
|
|
||||||
|
ModdedStartup.reinjectPersistentDimensions(server);
|
||||||
|
|
||||||
|
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.contains("iris:moon")
|
||||||
|
&& line.startsWith("Iris refused"))), times(1));
|
||||||
|
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
|
||||||
|
&& line.contains("generation history is unusable"))), times(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
NativeProtocolPlayer operator = mock(NativeProtocolPlayer.class);
|
||||||
|
when(operator.isGameMaster()).thenReturn(true);
|
||||||
|
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
|
||||||
|
ModdedStartup.warnStartupFailuresTo(operator);
|
||||||
|
ModdedStartup.warnStartupFailuresTo(player);
|
||||||
|
|
||||||
|
verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon")
|
||||||
|
&& message.contains("generation history is unusable")));
|
||||||
|
verify(player, never()).sendMessage(anyString());
|
||||||
|
|
||||||
|
ModdedStartup.reset();
|
||||||
|
NativeProtocolPlayer laterOperator = mock(NativeProtocolPlayer.class);
|
||||||
|
when(laterOperator.isGameMaster()).thenReturn(true);
|
||||||
|
ModdedStartup.warnStartupFailuresTo(laterOperator);
|
||||||
|
verify(laterOperator, never()).sendMessage(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -372,8 +372,7 @@ public final class BukkitWorldConfiguration {
|
|||||||
+ "\" is not a string.");
|
+ "\" is not a string.");
|
||||||
}
|
}
|
||||||
String configuredGenerator = generator.trim();
|
String configuredGenerator = generator.trim();
|
||||||
if (!configuredGenerator.equalsIgnoreCase("Iris")
|
if (!isIrisGenerator(configuredGenerator)) {
|
||||||
&& !configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +394,35 @@ public final class BukkitWorldConfiguration {
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when any bukkit.yml world names Iris as its generator, vanilla slots included. CraftServer asks a plugin
|
||||||
|
* for a generator only for these worlds, so they are the ones a disabled Iris would hand to vanilla.
|
||||||
|
*/
|
||||||
|
public static boolean configuresIrisGenerator(File configurationFile) throws IOException {
|
||||||
|
Path configurationPath = Objects.requireNonNull(configurationFile, "configurationFile").toPath();
|
||||||
|
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
synchronized (MUTATION_LOCK) {
|
||||||
|
ConfigurationSection worlds = load(configurationFile).getConfigurationSection("worlds");
|
||||||
|
if (worlds == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String worldName : worlds.getKeys(false)) {
|
||||||
|
if (worlds.get(worldName + ".generator") instanceof String generator
|
||||||
|
&& isIrisGenerator(generator.trim())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isIrisGenerator(String configuredGenerator) {
|
||||||
|
return configuredGenerator.equalsIgnoreCase("Iris")
|
||||||
|
|| configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
public static GeneratorReplacement replaceIfMatching(
|
public static GeneratorReplacement replaceIfMatching(
|
||||||
File configurationFile,
|
File configurationFile,
|
||||||
String worldName,
|
String worldName,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public final class MissingWorldStorageLog {
|
|||||||
}
|
}
|
||||||
emit(new String[]{
|
emit(new String[]{
|
||||||
IrisLogging.format("Iris world %s has an empty world folder at %s: no pack snapshot, no region"
|
IrisLogging.format("Iris world %s has an empty world folder at %s: no pack snapshot, no region"
|
||||||
+ " data; it will generate as vanilla terrain.", worldName, worldStorage),
|
+ " data; Iris refuses to generate it and no other generator gets it.", worldName, worldStorage),
|
||||||
IrisLogging.format("Delete that folder, or drop the world with /iris remove world=%s delete=true.",
|
IrisLogging.format("Delete that folder, or drop the world with /iris remove world=%s delete=true.",
|
||||||
worldName)
|
worldName)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package art.arcane.iris.world.safeguard;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.IdentityHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The framed console block Iris prints when it refuses to generate a world. Every platform prints the same shape
|
||||||
|
* so a refusal is found the same way in any server log.
|
||||||
|
*/
|
||||||
|
public final class GenerationRefusalNotice {
|
||||||
|
private static final String RULE = "=".repeat(78);
|
||||||
|
|
||||||
|
private GenerationRefusalNotice() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> compose(String headline, List<String> causes, List<String> consequences) {
|
||||||
|
List<String> lines = new ArrayList<>(causes.size() + consequences.size() + 3);
|
||||||
|
lines.add(RULE);
|
||||||
|
lines.add(Objects.requireNonNull(headline, "headline"));
|
||||||
|
for (int index = 0; index < causes.size(); index++) {
|
||||||
|
lines.add((index == 0 ? "Cause: " : "Caused by: ") + causes.get(index));
|
||||||
|
}
|
||||||
|
lines.addAll(consequences);
|
||||||
|
lines.add(RULE);
|
||||||
|
return List.copyOf(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every distinct message in the cause chain, outermost first. A wrapper whose message only repeats its cause
|
||||||
|
* (CompletionException, a rethrow with the same text) adds nothing and is skipped.
|
||||||
|
*/
|
||||||
|
public static List<String> causes(Throwable failure) {
|
||||||
|
List<String> causes = new ArrayList<>();
|
||||||
|
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||||
|
Throwable current = failure;
|
||||||
|
while (current != null && seen.add(current)) {
|
||||||
|
Throwable cause = current.getCause();
|
||||||
|
String message = current.getMessage();
|
||||||
|
boolean repeatsCause = cause != null && cause.toString().equals(message);
|
||||||
|
if (!repeatsCause) {
|
||||||
|
String text = message == null || message.isBlank() ? current.getClass().getSimpleName() : message.trim();
|
||||||
|
if (causes.isEmpty() || !causes.getLast().equals(text)) {
|
||||||
|
causes.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = cause;
|
||||||
|
}
|
||||||
|
return List.copyOf(causes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String summary(Throwable failure) {
|
||||||
|
return String.join("; ", causes(failure));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,33 @@ public class BukkitWorldConfigurationTest {
|
|||||||
)), bindings);
|
)), bindings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CraftServer only asks a plugin for a generator when bukkit.yml names it, so any world bound to Iris - a vanilla
|
||||||
|
* slot included - is one a disabled Iris would hand to the vanilla generator.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void detectsAnyWorldBoundToIrisIncludingVanillaSlots() throws Exception {
|
||||||
|
File overworld = temporaryFolder.newFile("iris-overworld.yml");
|
||||||
|
YamlConfiguration yaml = new YamlConfiguration();
|
||||||
|
yaml.set("worlds.world.generator", "iris:overworld");
|
||||||
|
yaml.set("worlds.world_nether.generator", "Other:generator");
|
||||||
|
yaml.save(overworld);
|
||||||
|
File bare = temporaryFolder.newFile("iris-bare.yml");
|
||||||
|
yaml = new YamlConfiguration();
|
||||||
|
yaml.set("worlds.world_the_end.generator", "Iris");
|
||||||
|
yaml.save(bare);
|
||||||
|
File other = temporaryFolder.newFile("other-only.yml");
|
||||||
|
yaml = new YamlConfiguration();
|
||||||
|
yaml.set("worlds.world.generator", "Irish:overworld");
|
||||||
|
yaml.set("worlds.world_nether.seed", 1337L);
|
||||||
|
yaml.save(other);
|
||||||
|
|
||||||
|
assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(overworld));
|
||||||
|
assertTrue(BukkitWorldConfiguration.configuresIrisGenerator(bare));
|
||||||
|
assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(other));
|
||||||
|
assertFalse(BukkitWorldConfiguration.configuresIrisGenerator(new File(temporaryFolder.getRoot(), "absent.yml")));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void rejectsCustomIrisBindingWithoutSelectedDimension() throws Exception {
|
public void rejectsCustomIrisBindingWithoutSelectedDimension() throws Exception {
|
||||||
File configuration = temporaryFolder.newFile("missing-binding-dimension.yml");
|
File configuration = temporaryFolder.newFile("missing-binding-dimension.yml");
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package art.arcane.iris.world.safeguard;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletionException;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class GenerationRefusalNoticeTest {
|
||||||
|
@Test
|
||||||
|
public void causesListEveryDistinctMessageOutermostFirst() {
|
||||||
|
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
|
||||||
|
new IOException("Historical generated registry definition changed",
|
||||||
|
new IllegalArgumentException()));
|
||||||
|
|
||||||
|
assertEquals(List.of(
|
||||||
|
"Iris generation history is unusable at /srv/moon.",
|
||||||
|
"Historical generated registry definition changed",
|
||||||
|
"IllegalArgumentException"
|
||||||
|
), GenerationRefusalNotice.causes(failure));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void wrappersThatOnlyRepeatTheirCauseAreSkipped() {
|
||||||
|
IllegalStateException root = new IllegalStateException("history drifted");
|
||||||
|
CompletionException wrapper = new CompletionException(root);
|
||||||
|
|
||||||
|
assertEquals(List.of("history drifted"), GenerationRefusalNotice.causes(wrapper));
|
||||||
|
assertEquals(List.of("history drifted"), GenerationRefusalNotice.causes(
|
||||||
|
new IllegalStateException("history drifted", root)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aCyclicCauseChainTerminates() {
|
||||||
|
IllegalStateException first = new IllegalStateException("first");
|
||||||
|
IllegalStateException second = new IllegalStateException("second", first);
|
||||||
|
first.initCause(second);
|
||||||
|
|
||||||
|
assertEquals(List.of("first", "second"), GenerationRefusalNotice.causes(first));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void summaryJoinsTheCausesOnOneLine() {
|
||||||
|
assertEquals("outer; inner", GenerationRefusalNotice.summary(
|
||||||
|
new IllegalStateException("outer", new IOException("inner"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void noticeIsFramedAndNamesCauseChainAndConsequences() {
|
||||||
|
List<String> lines = GenerationRefusalNotice.compose(
|
||||||
|
"Iris refused to generate world 'moon' (iris:moon)",
|
||||||
|
List.of("outer", "inner"),
|
||||||
|
List.of("No chunks are written to it.", "Server startup stops before this world loads."));
|
||||||
|
|
||||||
|
assertEquals(lines.getFirst(), lines.getLast());
|
||||||
|
assertTrue(lines.getFirst(), lines.getFirst().chars().allMatch(character -> character == '='));
|
||||||
|
assertEquals(List.of(
|
||||||
|
"Iris refused to generate world 'moon' (iris:moon)",
|
||||||
|
"Cause: outer",
|
||||||
|
"Caused by: inner",
|
||||||
|
"No chunks are written to it.",
|
||||||
|
"Server startup stops before this world loads."
|
||||||
|
), lines.subList(1, lines.size() - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ import static org.junit.Assert.assertTrue;
|
|||||||
/**
|
/**
|
||||||
* A boot that ends in Danger Mode used to say so only in the banner. The server then ran on with every
|
* A boot that ends in Danger Mode used to say so only in the banner. The server then ran on with every
|
||||||
* configured Iris world bound to a generator that throws on the first chunk, and nothing after the banner
|
* configured Iris world bound to a generator that throws on the first chunk, and nothing after the banner
|
||||||
* said that out loud. {@code refuseVanillaFallback} never covers this case because it only runs when enable
|
* said that out loud. The failed-enable lock never covers this case because it only runs when enable itself
|
||||||
* itself fails.
|
* fails.
|
||||||
*/
|
*/
|
||||||
public class RuntimeLockNoticeTest {
|
public class RuntimeLockNoticeTest {
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user