Fail-closed world generation

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

No files matched your search

@@ -0,0 +1,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;
}
}
@@ -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;
}
}
@@ -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());
}
}