Fail-closed review fixes

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

No files matched your search

@@ -1,14 +1,20 @@
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.IrisStartupValidation;
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 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.bukkit.plugin.PluginManager;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
@@ -16,7 +22,9 @@ import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.File;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.util.List;
import java.util.UUID;
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.assertThrows;
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.mockStatic;
import static org.mockito.Mockito.never;
@@ -40,6 +51,7 @@ public class FailedEnableLockTest {
@After
public void disableValidation() {
IrisStartupValidation.disable();
IrisPlatforms.unbind();
}
@Test
@@ -49,7 +61,7 @@ public class FailedEnableLockTest {
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
assertTrue(FailedEnableLock.engage(failure, true));
assertTrue(FailedEnableLock.engage(failure, true, false));
String denial = IrisStartupValidation.denialReason().orElseThrow();
assertTrue(denial, denial.contains("service graph exploded"));
@@ -74,7 +86,7 @@ public class FailedEnableLockTest {
IrisStartupValidation.begin();
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();
@@ -84,7 +96,7 @@ public class FailedEnableLockTest {
@Test
public void noticeSaysIrisStaysLockedAndWhatThatBlocks() {
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("outer"));
@@ -94,6 +106,82 @@ public class FailedEnableLockTest {
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
public void irisWorldsAreManagedStorageOrAnyBukkitBinding() throws Exception {
File levelRoot = temporaryFolder.newFolder("level", "world");
@@ -110,4 +198,43 @@ public class FailedEnableLockTest {
assertTrue(new File(levelRoot, "dimensions/iris/moon").mkdirs());
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.GenerationHistoryRuntimeRouter;
import art.arcane.iris.world.history.GenerationManifest;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
@@ -26,9 +28,11 @@ import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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 {
Path container = temporaryFolder.newFolder().toPath();
Path root = container.resolve("world/dimensions/iris/probe");
@@ -134,11 +165,14 @@ public class BukkitWorldReconcilerRuntimeTest {
when(dimension.getLoadKey()).thenReturn("overworld");
when(engine.getData()).thenReturn(data);
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");
Constructor<?> constructor = backendClass.getDeclaredConstructor(Iris.class);
constructor.setAccessible(true);
BukkitWorldReconciler.Backend backend = (BukkitWorldReconciler.Backend) constructor.newInstance(mock(Iris.class));
return new RuntimeFixture(backend, world, key, generator, engine, history, root, pack);
return (BukkitWorldReconciler.Backend) constructor.newInstance(plugin);
}
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("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() {
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"));
}
/**
* A vanilla slot bound to Iris in bukkit.yml with no Iris storage stops startup, so no command can run until the
* binding is gone. /iris create is for new worlds anyway; a vanilla slot is taken over with /iris replace.
*/
@Test
public void vanillaDimensionSlotWithoutFrozenPackFailsClosed() throws Exception {
File worldContainer = temporaryFolder.newFolder("vanilla-slot");
@@ -500,7 +482,10 @@ public class IrisWorldGeneratorResolverTest {
.resolveDefaultWorldGenerator("world_nether", "overworld"));
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());
}
}
@@ -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;
}
}