Fail-closed review fixes

- Modded logins are refused only when this save's dimension registry lists the primary world
- Otherwise Iris warns once and leaves routing idle
- An unreadable dimension registry counts as expecting the primary world
- A vanilla slot bound to Iris without Iris storage points at the bukkit.yml entry and /iris replace
- The refusal consequence comes from whether startup worlds exist instead of the first tick
- The first WorldLoadEvent or a hotload marks startup worlds as existing
- A failed enable on a hotload says the server keeps running
- Reconciler resolution failures report the refusal banner
- Operators get a join notice for such failures until the world loads
- The fail-closed generator is built even when the cause message cannot be read
- Tests drive Iris.onEnable for the lock, the unsupported-server lock and the rethrow
- The failed-enable teardown decision lives in FailedEnableLock with a test
This commit is contained in:
Brian Neumann-Fopiano
2026-09-29 06:30:28 -04:00
parent 3a4da2086e
commit eefcbc40ef
13 changed files with 644 additions and 84 deletions
@@ -26,12 +26,12 @@ final class FailedEnableLock {
/** /**
* @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before * @return true when Iris must stay enabled and locked, false when the failure may disable Iris as before
*/ */
static boolean engage(Throwable failure, boolean irisWorldsPresent) { static boolean engage(Throwable failure, boolean irisWorldsPresent, boolean startupWorldsCreated) {
if (!irisWorldsPresent) { if (!irisWorldsPresent) {
return false; return false;
} }
IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure)); IrisStartupValidation.markRuntimeInvalid("Iris failed to enable: " + GenerationRefusalNotice.summary(failure));
List<String> notice = notice(failure); List<String> notice = notice(failure, startupWorldsCreated);
try { try {
for (String line : notice) { for (String line : notice) {
Iris.error(line); Iris.error(line);
@@ -45,14 +45,29 @@ final class FailedEnableLock {
return true; return true;
} }
static List<String> notice(Throwable failure) { /**
* Deferring teardown to the server stop quiesces through the NMS binding and waits for generators Paper closes.
* A failed enable may have neither, so it tears down at once even while the server stops.
*/
static boolean defersTeardownToServerStop(boolean enableFailed, boolean serverStopping) {
return !enableFailed && serverStopping;
}
/**
* @param startupWorldsCreated true when Iris enabled on a running server (a hotload), whose startup worlds
* already exist, so nothing stops
*/
static List<String> notice(Throwable failure, boolean startupWorldsCreated) {
return GenerationRefusalNotice.compose( return GenerationRefusalNotice.compose(
"Iris failed to enable", "Iris failed to enable",
GenerationRefusalNotice.causes(failure), GenerationRefusalNotice.causes(failure),
List.of( List.of(
"Iris stays enabled with a locked runtime so no Iris world falls back to vanilla generation.", "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," startupWorldsCreated
+ " and no chunks are written.", ? "Every Iris world that is not loaded yet refuses to load; the server keeps running"
+ " and no chunks are written for them."
: "Every Iris world refuses to generate: server startup stops before the first one"
+ " loads, and no chunks are written.",
"Player logins are refused until this is fixed and the server restarts." "Player logins are refused until this is fixed and the server restarts."
)); ));
} }
@@ -34,6 +34,7 @@ import art.arcane.iris.world.BukkitWorldReconciler;
import art.arcane.iris.world.IrisWorldGeneratorResolver; import art.arcane.iris.world.IrisWorldGeneratorResolver;
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.world.WorldRefusalReporter;
import art.arcane.iris.configuration.SettingsHotloadWatch; import art.arcane.iris.configuration.SettingsHotloadWatch;
import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.pack.datapack.DatapackIngestService;
@@ -754,7 +755,6 @@ 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);
@@ -900,6 +900,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisStartupValidation.begin(); IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
try { try {
generatorResolver.refusals().attach(this);
IrisPlatforms.bind(new BukkitPlatform()); IrisPlatforms.bind(new BukkitPlatform());
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this); Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
pendingWorldReplacements.registerPlatformEntryListener(); pendingWorldReplacements.registerPlatformEntryListener();
@@ -909,7 +910,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
enable(); enable();
} catch (Throwable failure) { } catch (Throwable failure) {
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) { if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
generatorResolver.refusals().startupWorldsCreated())) {
throw failure; throw failure;
} }
enableFailed = true; enableFailed = true;
@@ -933,7 +935,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
? "no NMS binding is available for this server version." ? "no NMS binding is available for this server version."
: bindFailure.getMessage()), bindFailure); : bindFailure.getMessage()), bindFailure);
if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent())) { if (FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
generatorResolver.refusals().startupWorldsCreated())) {
enableFailed = true; enableFailed = true;
return; return;
} }
@@ -980,13 +983,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
IrisLanguage.shutdown(); IrisLanguage.shutdown();
teardownPapi(); teardownPapi();
// A failed enable left no generator for Paper's shutdown to close, so it tears down the way a failed boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping());
// 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);
} else if (serverStopping) { } else if (deferToServerStop) {
quiesceRuntimeForServerShutdown("onDisable"); quiesceRuntimeForServerShutdown("onDisable");
startPostStopFinisher(); startPostStopFinisher();
} else { } else {
@@ -1004,7 +1005,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
// super.onDisable() cancels plugin tasks and unregisters every listener. // super.onDisable() cancels plugin tasks and unregisters every listener.
super.onDisable(); super.onDisable();
if (!serverStopping || restartingAtStartupBoundary) { if (!deferToServerStop || restartingAtStartupBoundary) {
finishTerminalCleanup(); finishTerminalCleanup();
} }
} }
@@ -1429,6 +1430,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return generatorResolver.requireWorldGenerator(worldName, id); return generatorResolver.requireWorldGenerator(worldName, id);
} }
public WorldRefusalReporter worldRefusals() {
return generatorResolver.refusals();
}
public void splash() { public void splash() {
Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes()); Iris.info("Custom Biomes: " + INMS.get().countCustomBiomes());
printPacks(); printPacks();
@@ -722,19 +722,30 @@ public final class BukkitWorldReconciler {
return WorldIdentity.resolve(worldKey); return WorldIdentity.resolve(worldKey);
} }
/**
* A world Iris cannot resolve is refused like one CraftServer asked for, with the banner and operator notice,
* then fails this load only.
*/
@Override @Override
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) { public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
String configuredWorldName = worldKey.toString();
ChunkGenerator generator;
IrisDimension irisDimension;
try { try {
String logicalWorldName = IrisWorldStorage.logicalName(worldKey); configuredWorldName = configuredWorldName(worldKey);
String configuredWorldName = configuredWorldName(worldKey); Iris.info("Loading World: %s | Generator: %s", IrisWorldStorage.logicalName(worldKey), dimension);
Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension); generator = plugin.requireWorldGenerator(configuredWorldName, dimension);
ChunkGenerator generator = plugin.requireWorldGenerator(configuredWorldName, dimension); 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 + "\".");
} }
} catch (Throwable refusal) {
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "..."); plugin.worldRefusals().report(configuredWorldName, worldKey, refusal);
return CompletableFuture.failedFuture(refusal);
}
try {
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + IrisWorldStorage.logicalName(worldKey)
+ " using Iris:" + dimension + "...");
WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey) WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey)
.generator(generator) .generator(generator)
.environment(BukkitEnvironment.from(irisDimension.getEnvironment())); .environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
@@ -49,9 +49,18 @@ final class IrisFailClosedChunkGenerator extends ChunkGenerator {
+ "' remains locked: " + denialReason); + "' remains locked: " + denialReason);
} }
/**
* Built on the way back to CraftServer, which turns any throw into the vanilla generator, so a cause that cannot
* be summarized is named by its type instead.
*/
static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) { static IrisFailClosedChunkGenerator refused(String worldName, Throwable failure) {
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " String reason;
+ GenerationRefusalNotice.summary(failure)); try {
reason = GenerationRefusalNotice.summary(failure);
} catch (Throwable unreadable) {
reason = failure.getClass().getName();
}
return new IrisFailClosedChunkGenerator("Iris refuses to generate '" + worldName + "': " + reason);
} }
@Override @Override
@@ -47,7 +47,6 @@ import art.arcane.iris.world.history.GenerationHistoryPaths;
import art.arcane.iris.world.history.GenerationPackFingerprint; import art.arcane.iris.world.history.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;
@@ -91,7 +90,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; private final WorldRefusalReporter refusals = new WorldRefusalReporter();
public IrisWorldGeneratorResolver(VolmitPlugin plugin) { public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
@@ -533,48 +532,30 @@ public final class IrisWorldGeneratorResolver {
return resolveFrozenWorldGenerator(worldName, dimension); return resolveFrozenWorldGenerator(worldName, dimension);
} }
/** public WorldRefusalReporter refusals() {
* Startup worlds are created before the first server tick; anything asked for after it is a runtime load. return refusals;
*/
public void markServerRunning() {
serverRunning = true;
} }
private ChunkGenerator refuseWorld(String worldName, Throwable failure) { private ChunkGenerator refuseWorld(String worldName, Throwable failure) {
ChunkGenerator refused = IrisFailClosedChunkGenerator.refused(worldName, failure); refusals.report(worldName, refusalKey(worldName), failure);
try { try {
for (String line : refusalNotice(worldName, refusalKey(worldName), failure, serverRunning)) {
Iris.error(line);
}
Iris.reportError(failure); Iris.reportError(failure);
} catch (Throwable loggingFailure) { } catch (Throwable loggingFailure) {
failure.addSuppressed(loggingFailure); System.err.println("[Iris] Could not log the refusal of '" + worldName + "': "
failure.printStackTrace(System.err); + loggingFailure.getClass().getName());
} }
return refused; return IrisFailClosedChunkGenerator.refused(worldName, failure);
} }
private static String refusalKey(String worldName) { @Nullable
private static NamespacedKey refusalKey(String worldName) {
try { try {
return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName()).toString(); return messageWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
} catch (Throwable unresolved) { } catch (Throwable unresolved) {
return "unresolved key"; return null;
} }
} }
static List<String> refusalNotice(String worldName, String worldKey, Throwable failure, boolean serverRunning) {
return GenerationRefusalNotice.compose(
"Iris refused to generate world '" + worldName + "' (" + worldKey + ")",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris does not generate this world and does not let vanilla or any other generator"
+ " write it; no chunks are written.",
serverRunning
? "The world does not load; the server keeps running. Fix the cause, then load it again."
: "Server startup stops before this world loads. Fix the cause, then start the server again."
));
}
@Nullable @Nullable
private static ChunkGenerator startupLockedGenerator(String worldName) { private static ChunkGenerator startupLockedGenerator(String worldName) {
Optional<String> startupDenial = IrisStartupValidation.denialReason(); Optional<String> startupDenial = IrisStartupValidation.denialReason();
@@ -640,6 +621,14 @@ public final class IrisWorldGeneratorResolver {
return; return;
} }
NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName()); NamespacedKey messageKey = messageWorldKey(worldName, levelRoot.getName());
if (!IRIS_DIMENSION_NAMESPACE.equals(messageKey.getNamespace())) {
// Only bukkit.yml binds a vanilla slot at startup, and the refusal stops startup, so no command can run
// until that binding is gone.
throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ ") is a vanilla world slot with no Iris world storage, so Iris cannot generate it."
+ " Remove worlds." + worldName + ".generator from bukkit.yml, start the server, then run"
+ " /iris replace " + messageKey + " type=<pack> to replace it with Iris on the next restart.");
}
throw new IllegalStateException("'" + worldName + "' (" + messageKey throw new IllegalStateException("'" + worldName + "' (" + messageKey
+ ") has no Iris world storage, so Iris cannot generate it." + ") has no Iris world storage, so Iris cannot generate it."
+ " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey) + " Create Iris worlds with /iris create " + IrisWorldStorage.logicalName(messageKey)
@@ -0,0 +1,122 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.world;
import art.arcane.iris.Iris;
import art.arcane.iris.localization.C;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentSkipListMap;
/**
* Reports Iris worlds that refused to generate: a console banner that says what the refusal does to the server, and
* an operator notice on join for as long as the world stays unloaded, since the banner scrolls away.
*/
public final class WorldRefusalReporter implements Listener {
private static final String UNRESOLVED_KEY = "unresolved key";
private final Map<String, String> operatorNotices = new ConcurrentSkipListMap<>();
private volatile boolean startupWorldsCreated;
/**
* Iris enabled on a running server (a hotload) finds the startup worlds already loaded.
*/
public void attach(Plugin plugin) {
if (!Bukkit.getWorlds().isEmpty()) {
startupWorldsCreated = true;
}
Bukkit.getPluginManager().registerEvents(this, plugin);
}
/**
* Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
* A generator asked for after that belongs to a runtime create, which fails only its caller.
*/
public boolean startupWorldsCreated() {
return startupWorldsCreated;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) {
startupWorldsCreated = true;
try {
operatorNotices.remove(WorldIdentity.key(event.getWorld()).toString());
} catch (RuntimeException unkeyed) {
Iris.debug("Loaded world " + event.getWorld().getName() + " has no key to clear a refusal notice by");
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerJoin(PlayerJoinEvent event) {
if (operatorNotices.isEmpty()) {
return;
}
Player player = event.getPlayer();
if (!player.isOp() && !player.hasPermission("iris.all")) {
return;
}
for (String notice : operatorNotices.values()) {
player.sendMessage(C.RED + notice);
}
}
/**
* Never throws: the caller is handing CraftServer a fail-closed generator or failing a load, and a reporting
* failure must not turn either into something else.
*/
public void report(String worldName, @Nullable NamespacedKey worldKey, Throwable failure) {
String key = worldKey == null ? UNRESOLVED_KEY : worldKey.toString();
try {
for (String line : notice(worldName, key, failure, startupWorldsCreated)) {
Iris.error(line);
}
operatorNotices.put(worldKey == null ? worldName : key, "Iris world '" + worldName + "' (" + key
+ ") did not load and refuses to generate: " + GenerationRefusalNotice.summary(failure));
} catch (Throwable reportingFailure) {
System.err.println("[Iris] Iris refused to generate world '" + worldName + "' (" + key
+ "); the refusal could not be reported: " + reportingFailure.getClass().getName());
}
}
static List<String> notice(String worldName, String worldKey, Throwable failure, boolean startupWorldsCreated) {
return GenerationRefusalNotice.compose(
"Iris refused to generate world '" + worldName + "' (" + worldKey + ")",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris does not generate this world and does not let vanilla or any other generator"
+ " write it; no chunks are written.",
startupWorldsCreated
? "The world does not load; the server keeps running. Fix the cause, then load it again."
: "Server startup stops before this world loads. Fix the cause, then start the server again."
));
}
}
@@ -1,14 +1,20 @@
package art.arcane.iris; package art.arcane.iris;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.IrisStartupAdmissionListener; import art.arcane.iris.world.IrisStartupAdmissionListener;
import art.arcane.iris.world.IrisStartupValidation; import art.arcane.iris.world.IrisStartupValidation;
import art.arcane.iris.world.IrisWorldGeneratorResolver; import art.arcane.iris.world.IrisWorldGeneratorResolver;
import art.arcane.iris.world.PendingWorldReplacementManager;
import art.arcane.iris.world.task.J;
import com.destroystokyo.paper.profile.PlayerProfile; import com.destroystokyo.paper.profile.PlayerProfile;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent; import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
import org.bukkit.generator.WorldInfo; import org.bukkit.generator.WorldInfo;
import org.bukkit.plugin.PluginManager;
import org.junit.After; import org.junit.After;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@@ -16,7 +22,9 @@ import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import java.io.File; import java.io.File;
import java.lang.reflect.Field;
import java.net.InetAddress; import java.net.InetAddress;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -24,6 +32,9 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -40,6 +51,7 @@ public class FailedEnableLockTest {
@After @After
public void disableValidation() { public void disableValidation() {
IrisStartupValidation.disable(); IrisStartupValidation.disable();
IrisPlatforms.unbind();
} }
@Test @Test
@@ -49,7 +61,7 @@ public class FailedEnableLockTest {
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)) {
assertTrue(FailedEnableLock.engage(failure, true)); assertTrue(FailedEnableLock.engage(failure, true, false));
String denial = IrisStartupValidation.denialReason().orElseThrow(); String denial = IrisStartupValidation.denialReason().orElseThrow();
assertTrue(denial, denial.contains("service graph exploded")); assertTrue(denial, denial.contains("service graph exploded"));
@@ -74,7 +86,7 @@ public class FailedEnableLockTest {
IrisStartupValidation.begin(); IrisStartupValidation.begin();
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) { try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false)); assertFalse(FailedEnableLock.engage(new IllegalStateException("service graph exploded"), false, false));
} }
String denial = IrisStartupValidation.denialReason().orElseThrow(); String denial = IrisStartupValidation.denialReason().orElseThrow();
@@ -84,7 +96,7 @@ public class FailedEnableLockTest {
@Test @Test
public void noticeSaysIrisStaysLockedAndWhatThatBlocks() { public void noticeSaysIrisStaysLockedAndWhatThatBlocks() {
String notice = String.join("\n", FailedEnableLock.notice( String notice = String.join("\n", FailedEnableLock.notice(
new IllegalStateException("outer", new IllegalArgumentException("inner")))); new IllegalStateException("outer", new IllegalArgumentException("inner")), false));
assertTrue(notice, notice.contains("Iris failed to enable")); assertTrue(notice, notice.contains("Iris failed to enable"));
assertTrue(notice, notice.contains("outer")); assertTrue(notice, notice.contains("outer"));
@@ -94,6 +106,82 @@ public class FailedEnableLockTest {
assertTrue(notice, notice.contains("logins")); assertTrue(notice, notice.contains("logins"));
} }
/**
* A hotload enables Iris on a running server whose startup worlds already exist, so nothing stops.
*/
@Test
public void noticeAfterStartupWorldsExistSaysTheServerKeepsRunning() {
String notice = String.join("\n", FailedEnableLock.notice(new IllegalStateException("outer"), true));
assertFalse(notice, notice.contains("startup stops"));
assertTrue(notice, notice.contains("keeps running"));
}
/**
* IrisPlatforms.bind refuses a second platform, which is a real way for enable to throw. It has to throw inside
* the guarded region, and the throw must not reach Paper.
*/
@Test
public void onEnableKeepsIrisEnabledAndLockedWhenEnableThrowsOnAServerWithIrisWorlds() throws Exception {
IrisPlatforms.bind(mock(IrisPlatform.class));
Iris plugin = plugin();
try (EnableFixture fixture = new EnableFixture(true)) {
plugin.onEnable();
String denial = IrisStartupValidation.denialReason().orElseThrow();
assertTrue(denial, denial.contains("already bound"));
ChunkGenerator generator = plugin.getDefaultWorldGenerator("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("already bound"));
fixture.bukkit.verify(Bukkit::shutdown, never());
}
}
@Test
public void onEnableStillThrowsToPaperWhenTheServerHasNoIrisWorlds() throws Exception {
IrisPlatforms.bind(mock(IrisPlatform.class));
Iris plugin = plugin();
try (EnableFixture fixture = new EnableFixture(false)) {
IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable);
assertTrue(failure.getMessage(), failure.getMessage().contains("already bound"));
}
}
@Test
public void anUnsupportedServerWithIrisWorldsLocksInsteadOfDisablingIris() throws Exception {
Iris plugin = plugin();
set(plugin, "pendingWorldReplacements", mock(PendingWorldReplacementManager.class));
try (EnableFixture fixture = new EnableFixture(true);
MockedStatic<INMS> nms = mockStatic(INMS.class);
MockedStatic<J> scheduler = mockStatic(J.class)) {
nms.when(INMS::isBound).thenReturn(false);
nms.when(INMS::bindFailure).thenReturn(new IllegalStateException("no NMS binding for 26.9"));
plugin.onEnable();
String denial = IrisStartupValidation.denialReason().orElseThrow();
assertTrue(denial, denial.contains("no NMS binding for 26.9"));
scheduler.verify(() -> J.s(any(Runnable.class), anyInt()), never());
}
}
/**
* Deferring to the server stop quiesces through the NMS binding and waits for generators a failed enable never
* made, so a failed enable tears down at once even while the server stops.
*/
@Test
public void aFailedEnableNeverDefersItsTeardownToTheServerStop() {
assertFalse(FailedEnableLock.defersTeardownToServerStop(true, true));
assertFalse(FailedEnableLock.defersTeardownToServerStop(true, false));
assertTrue(FailedEnableLock.defersTeardownToServerStop(false, true));
assertFalse(FailedEnableLock.defersTeardownToServerStop(false, false));
}
@Test @Test
public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception { public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception {
File levelRoot = temporaryFolder.newFolder("level", "world"); File levelRoot = temporaryFolder.newFolder("level", "world");
@@ -110,4 +198,43 @@ public class FailedEnableLockTest {
assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs()); assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs());
assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit)); assertTrue(FailedEnableLock.irisWorldsPresent(levelRoot, bukkit));
} }
/**
* Paper builds plugins through its class loader, so the plugin under test is the real class with its fields
* set by hand: the resolver getDefaultWorldGenerator delegates to, and whatever enable touches before failing.
*/
private static Iris plugin() throws Exception {
Iris plugin = mock(Iris.class, CALLS_REAL_METHODS);
set(plugin, "generatorResolver", new IrisWorldGeneratorResolver(plugin));
return plugin;
}
private static void set(Iris plugin, String name, Object value) throws Exception {
Field field = Iris.class.getDeclaredField(name);
field.setAccessible(true);
field.set(plugin, value);
}
/**
* irisWorldsPresent reads the real server.properties, which a test has none of, so the answer is set here.
*/
private static final class EnableFixture implements AutoCloseable {
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
private final MockedStatic<Iris> iris = mockStatic(Iris.class);
private final MockedStatic<FailedEnableLock> lock = mockStatic(FailedEnableLock.class, CALLS_REAL_METHODS);
private EnableFixture(boolean irisWorlds) {
PluginManager plugins = mock(PluginManager.class);
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
lock.when(FailedEnableLock::irisWorldsPresent).thenReturn(irisWorlds);
}
@Override
public void close() {
lock.close();
iris.close();
bukkit.close();
}
}
} }
@@ -11,12 +11,14 @@ import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationHistoryPaths; import art.arcane.iris.world.history.GenerationHistoryPaths;
import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.world.history.GenerationManifest; import art.arcane.iris.world.history.GenerationManifest;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.World; import org.bukkit.World;
@@ -26,9 +28,11 @@ import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class BukkitWorldReconcilerRuntimeTest { public class BukkitWorldReconcilerRuntimeTest {
@@ -95,6 +99,33 @@ public class BukkitWorldReconcilerRuntimeTest {
} }
} }
/**
* The startup reconcile and /iris load create worlds through the backend, so a world Iris refuses there needs
* the same banner and operator notice as one CraftServer asked for.
*/
@Test
public void aRefusedReconcilerCreateIsReportedAsARefusal() throws Exception {
NamespacedKey key = new NamespacedKey("iris", "probe");
IllegalStateException refusal = new IllegalStateException("Iris generation history is unusable at /srv/probe.");
Iris plugin = mock(Iris.class);
WorldRefusalReporter refusals = mock(WorldRefusalReporter.class);
when(plugin.worldRefusals()).thenReturn(refusals);
when(plugin.requireWorldGenerator("world_iris_probe", "overworld")).thenThrow(refusal);
try (MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisWorldStorage> storage = mockStatic(IrisWorldStorage.class)) {
storage.when(IrisWorldStorage::levelRoot).thenReturn(new File("world"));
storage.when(() -> IrisWorldStorage.configuredWorldName(key, "world")).thenReturn("world_iris_probe");
storage.when(() -> IrisWorldStorage.logicalName(key)).thenReturn("probe");
CompletableFuture<World> created = backend(plugin).createWorld(key, "overworld", 1337L);
ExecutionException failure = assertThrows(ExecutionException.class, created::get);
assertSame(refusal, failure.getCause());
verify(refusals).report("world_iris_probe", key, refusal);
}
}
private RuntimeFixture fixture() throws Exception { private RuntimeFixture fixture() throws Exception {
Path container = temporaryFolder.newFolder().toPath(); Path container = temporaryFolder.newFolder().toPath();
Path root = container.resolve("world/dimensions/iris/probe"); Path root = container.resolve("world/dimensions/iris/probe");
@@ -134,11 +165,14 @@ public class BukkitWorldReconcilerRuntimeTest {
when(dimension.getLoadKey()).thenReturn("overworld"); when(dimension.getLoadKey()).thenReturn("overworld");
when(engine.getData()).thenReturn(data); when(engine.getData()).thenReturn(data);
when(data.getDataFolder()).thenReturn(pack.toFile()); when(data.getDataFolder()).thenReturn(pack.toFile());
return new RuntimeFixture(backend(mock(Iris.class)), world, key, generator, engine, history, root, pack);
}
private static BukkitWorldReconciler.Backend backend(Iris plugin) throws Exception {
Class<?> backendClass = Class.forName(BukkitWorldReconciler.class.getName() + "$BukkitBackend"); Class<?> backendClass = Class.forName(BukkitWorldReconciler.class.getName() + "$BukkitBackend");
Constructor<?> constructor = backendClass.getDeclaredConstructor(Iris.class); Constructor<?> constructor = backendClass.getDeclaredConstructor(Iris.class);
constructor.setAccessible(true); constructor.setAccessible(true);
BukkitWorldReconciler.Backend backend = (BukkitWorldReconciler.Backend) constructor.newInstance(mock(Iris.class)); return (BukkitWorldReconciler.Backend) constructor.newInstance(plugin);
return new RuntimeFixture(backend, world, key, generator, engine, history, root, pack);
} }
private record RuntimeFixture(BukkitWorldReconciler.Backend backend, World world, NamespacedKey key, private record RuntimeFixture(BukkitWorldReconciler.Backend backend, World world, NamespacedKey key,
@@ -53,4 +53,25 @@ public class IrisFailClosedChunkGeneratorTest {
assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable")); assertTrue(refusal.getMessage(), refusal.getMessage().contains("generation history is unusable"));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed")); assertTrue(refusal.getMessage(), refusal.getMessage().contains("registry definition changed"));
} }
/**
* The refusal is built on the way back to CraftServer, which turns any throw into the vanilla generator, so an
* unreadable cause still has to produce a generator.
*/
@Test
public void aCauseWhoseMessageCannotBeReadStillRefuses() {
IllegalStateException unreadable = new IllegalStateException() {
@Override
public String getMessage() {
throw new UnsupportedOperationException("message unavailable");
}
};
ChunkGenerator generator = IrisFailClosedChunkGenerator.refused("world_iris_moon", unreadable);
IllegalStateException refusal = assertThrows(
IllegalStateException.class,
() -> generator.getDefaultBiomeProvider(mock(WorldInfo.class)));
assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_iris_moon"));
}
} }
@@ -464,28 +464,10 @@ public class IrisWorldGeneratorResolverTest {
} }
} }
@Test /**
public void refusalNoticeNamesTheWorldTheCauseChainAndWhatHappensNext() { * A vanilla slot bound to Iris in bukkit.yml with no Iris storage stops startup, so no command can run until the
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.", * binding is gone. /iris create is for new worlds anyway; a vanilla slot is taken over with /iris replace.
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 @Test
public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception { public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception {
File worldContainer = temporaryFolder.newFolder("vanilla-slot"); File worldContainer = temporaryFolder.newFolder("vanilla-slot");
@@ -500,7 +482,10 @@ public class IrisWorldGeneratorResolverTest {
.resolveDefaultWorldGenerator("world_nether", "overworld")); .resolveDefaultWorldGenerator("world_nether", "overworld"));
assertTrue(refusal, refusal.contains("minecraft:the_nether")); assertTrue(refusal, refusal.contains("minecraft:the_nether"));
assertTrue(refusal, refusal.contains("/iris create")); assertTrue(refusal, refusal.contains("worlds.world_nether.generator"));
assertTrue(refusal, refusal.contains("bukkit.yml"));
assertTrue(refusal, refusal.contains("/iris replace minecraft:the_nether type=<pack>"));
assertFalse(refusal, refusal.contains("/iris create"));
bukkit.verify(Bukkit::shutdown, never()); bukkit.verify(Bukkit::shutdown, never());
} }
} }
@@ -0,0 +1,145 @@
package art.arcane.iris.world;
import art.arcane.iris.Iris;
import net.kyori.adventure.text.Component;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginManager;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
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.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Whether a refused world stops startup depends on who asked for it: Paper's startup level creation aborts the
* server, a runtime create only fails its caller. The banner must not claim the wrong one.
*/
public class WorldRefusalReporterTest {
private static final NamespacedKey MOON = new NamespacedKey("iris", "moon");
@Test
public void aRefusalBeforeAnyWorldLoadedSaysStartupStops() {
String banner = reportAndCapture(new WorldRefusalReporter());
assertTrue(banner, banner.contains("'moon'"));
assertTrue(banner, banner.contains("iris:moon"));
assertTrue(banner, banner.contains("Iris generation history is unusable at /srv/moon."));
assertTrue(banner, banner.contains("Historical generated registry definition changed for iris:biomes/abc"));
assertTrue(banner, banner.contains("no chunks"));
assertTrue(banner, banner.contains("startup stops"));
}
/**
* Paper creates every startup level before it prepares any, and preparing one fires the first WorldLoadEvent.
* A plugin enabling at POSTWORLD creates its worlds after that, before the first tick, and the server keeps
* running when that create fails.
*/
@Test
public void aRefusalAfterTheFirstWorldLoadSaysTheServerKeepsRunning() {
WorldRefusalReporter reporter = new WorldRefusalReporter();
reporter.onWorldLoad(new WorldLoadEvent(world(NamespacedKey.minecraft("overworld"))));
String banner = reportAndCapture(reporter);
assertTrue(banner, banner.contains("keeps running"));
assertFalse(banner, banner.contains("startup stops"));
}
@Test
public void irisEnabledAfterWorldsExistTreatsEveryRefusalAsRuntime() {
WorldRefusalReporter reporter = new WorldRefusalReporter();
Plugin plugin = mock(Plugin.class);
PluginManager plugins = mock(PluginManager.class);
World overworld = world(NamespacedKey.minecraft("overworld"));
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::getWorlds).thenReturn(List.of(overworld));
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
reporter.attach(plugin);
}
assertTrue(reporter.startupWorldsCreated());
verify(plugins).registerEvents(reporter, plugin);
String banner = reportAndCapture(reporter);
assertTrue(banner, banner.contains("keeps running"));
}
@Test
public void operatorsHearAboutARefusedWorldOnJoinUntilItLoads() {
WorldRefusalReporter reporter = new WorldRefusalReporter();
reportAndCapture(reporter);
Player operator = player(true);
Player member = player(false);
reporter.onPlayerJoin(new PlayerJoinEvent(operator, Component.empty()));
reporter.onPlayerJoin(new PlayerJoinEvent(member, Component.empty()));
verify(operator).sendMessage(contains("iris:moon"));
verify(member, never()).sendMessage(anyString());
reporter.onWorldLoad(new WorldLoadEvent(world(MOON)));
Player later = player(true);
reporter.onPlayerJoin(new PlayerJoinEvent(later, Component.empty()));
verify(later, never()).sendMessage(anyString());
}
@Test
public void aFailureWhileReportingNeverEscapes() {
Throwable unreadable = new IllegalStateException() {
@Override
public String getMessage() {
throw new UnsupportedOperationException("message unavailable");
}
};
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
new WorldRefusalReporter().report("moon", MOON, unreadable);
}
}
private static String reportAndCapture(WorldRefusalReporter reporter) {
IllegalStateException failure = new IllegalStateException("Iris generation history is unusable at /srv/moon.",
new IOException("Historical generated registry definition changed for iris:biomes/abc"));
List<String> lines = new ArrayList<>();
try (MockedStatic<Iris> iris = mockStatic(Iris.class)) {
iris.when(() -> Iris.error(anyString(), any(Object[].class))).thenAnswer(invocation -> {
lines.add(invocation.getArgument(0, String.class));
return null;
});
reporter.report("moon", MOON, failure);
}
return String.join("\n", lines);
}
private static World world(NamespacedKey key) {
World world = mock(World.class);
when(world.getKey()).thenReturn(key);
when(world.getName()).thenReturn(key.getKey());
return world;
}
private static Player player(boolean operator) {
Player player = mock(Player.class);
when(player.isOp()).thenReturn(operator);
return player;
}
}
@@ -40,6 +40,7 @@ public final class ModdedPrimaryWorldRouter {
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 static volatile String loginRefusal;
private static volatile String absentPrimary;
private ModdedPrimaryWorldRouter() { private ModdedPrimaryWorldRouter() {
} }
@@ -48,6 +49,7 @@ public final class ModdedPrimaryWorldRouter {
routed.clear(); routed.clear();
inFlight.clear(); inFlight.clear();
loginRefusal = null; loginRefusal = null;
absentPrimary = null;
} }
/** /**
@@ -99,7 +101,7 @@ public final class ModdedPrimaryWorldRouter {
NativeWorld target = ModdedDimensionManager.level(server, primary); NativeWorld target = ModdedDimensionManager.level(server, primary);
if (target == null) { if (target == null) {
if (ModdedStartup.dimensionsRestored()) { if (ModdedStartup.dimensionsRestored()) {
refuseLogins(server, primary); judgeMissingPrimary(server, primary);
} }
return; return;
} }
@@ -141,9 +143,37 @@ public final class ModdedPrimaryWorldRouter {
}); });
} }
/**
* primaryWorld is instance-wide but the dimension it names belongs to one save. Only a save whose registry lists
* it expected it here, so only that save has a primary world that failed to load.
*/
private static void judgeMissingPrimary(NativeModdedServer server, String primary) {
if (loginRefusal == null && primary.equals(absentPrimary)) {
return;
}
if (loginRefusal == null && !expectedInThisSave(server, primary)) {
absentPrimary = primary;
ModdedIrisLog.warn("Iris primary world '" + primary + "' from config/irisworldgen/modded.json does not"
+ " exist in this save, so players are not routed. Create it here with /iris world replace-overworld"
+ " <pack> or clear primaryWorld.");
return;
}
refuseLogins(server, primary);
}
private static boolean expectedInThisSave(NativeModdedServer server, String primary) {
try {
return ModdedDimensionRegistryStore.get(server, primary) != null;
} catch (RuntimeException unreadable) {
ModdedIrisLog.error("Iris could not read this save's dimension registry to judge primary world '"
+ primary + "'", unreadable);
return true;
}
}
/** /**
* Players left in the overworld would generate the terrain the primary Iris world was configured to replace, so a * 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. * primary world this save expected and did not load refuses every player instead.
*/ */
private static void refuseLogins(NativeModdedServer server, String primary) { private static void refuseLogins(NativeModdedServer server, String primary) {
String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players." String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players."
@@ -152,7 +182,7 @@ public final class ModdedPrimaryWorldRouter {
for (String line : GenerationRefusalNotice.compose( for (String line : GenerationRefusalNotice.compose(
"Iris refused player logins: primary world '" + primary + "' is not loaded", "Iris refused player logins: primary world '" + primary + "' is not loaded",
List.of("routePlayersToPrimaryWorld sends players to '" + primary List.of("routePlayersToPrimaryWorld sends players to '" + primary
+ "', which did not load at startup."), + "', which this save's dimension registry expects but did not load at startup."),
List.of( List.of(
"Players are disconnected instead of playing in the overworld, so no chunks are" "Players are disconnected instead of playing in the overworld, so no chunks are"
+ " written there.", + " written there.",
@@ -37,7 +37,7 @@ public class ModdedPrimaryWorldRouterTest {
} }
@Test @Test
public void aMissingPrimaryWorldRefusesPlayersOnceStartupHasRestoredDimensions() { public void aPrimaryWorldThisSaveListsButDidNotRestoreRefusesPlayers() {
NativeModdedServer server = mock(NativeModdedServer.class); NativeModdedServer server = mock(NativeModdedServer.class);
NativeProtocolPlayer online = mock(NativeProtocolPlayer.class); NativeProtocolPlayer online = mock(NativeProtocolPlayer.class);
onlinePlayers(server, online); onlinePlayers(server, online);
@@ -45,10 +45,12 @@ public class ModdedPrimaryWorldRouterTest {
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class); try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class); MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class); MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) { MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
configs.when(ModdedModConfig::get).thenReturn(routed); configs.when(ModdedModConfig::get).thenReturn(routed);
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null); manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(null);
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted());
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
evaluate(server); evaluate(server);
@@ -63,6 +65,37 @@ public class ModdedPrimaryWorldRouterTest {
} }
} }
/**
* primaryWorld lives in the instance-wide modded.json, but the dimension it names lives in one save. Another
* save, a new world or a reset world folder never had it, so nothing failed there and nothing is refused.
*/
@Test
public void aPrimaryWorldThisSaveNeverHadLeavesRoutingIdleWithOneWarning() {
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<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.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);
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(null);
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
evaluate(server);
evaluate(server);
verify(online, never()).disconnect(anyString());
assertFalse(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
log.verify(() -> ModdedIrisLog.warn(argThat((String line) -> line != null && line.contains(PRIMARY))), times(1));
log.verify(() -> ModdedIrisLog.error(anyString()), never());
}
}
/** /**
* Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is * Joins are processed before the first Iris tick restores persistent dimensions, so a primary world that is
* missing then is still loading, not broken. * missing then is still loading, not broken.
@@ -100,8 +133,10 @@ public class ModdedPrimaryWorldRouterTest {
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class); try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class); MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.class);
MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class); MockedStatic<ModdedStartup> startup = mockStatic(ModdedStartup.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) { MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY)).thenReturn(persisted());
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true); startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
configs.when(ModdedModConfig::get).thenReturn(routed); configs.when(ModdedModConfig::get).thenReturn(routed);
evaluate(server); evaluate(server);
@@ -119,6 +154,38 @@ public class ModdedPrimaryWorldRouterTest {
} }
} }
/**
* A registry that cannot be read cannot prove the primary world was never here, so it counts as expected.
*/
@Test
public void anUnreadableRegistryRefusesPlayersRatherThanGuessing() {
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<ModdedDimensionRegistryStore> registry = mockStatic(ModdedDimensionRegistryStore.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);
registry.when(() -> ModdedDimensionRegistryStore.get(server, PRIMARY))
.thenThrow(new IllegalStateException("registry could not be read"));
startup.when(ModdedStartup::dimensionsRestored).thenReturn(true);
evaluate(server);
verify(online).disconnect(contains(PRIMARY));
assertTrue(ModdedPrimaryWorldRouter.refuseIfUnavailable(mock(NativeProtocolPlayer.class)));
}
}
private static ModdedDimensionRegistryStore.PersistentDimension persisted() {
return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L);
}
private static void evaluate(NativeModdedServer server) { private static void evaluate(NativeModdedServer server) {
for (int tick = 0; tick < TICK_INTERVAL; tick++) { for (int tick = 0; tick < TICK_INTERVAL; tick++) {
ModdedPrimaryWorldRouter.tick(server); ModdedPrimaryWorldRouter.tick(server);