Merge branch 'fail-closed' into review

This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-29 06:34:18 -04:00
commit a9f07ce5c6
25 files changed
+1670 -161

No files matched your search

@@ -59,6 +59,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeGeneratorContext;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedStructureStage;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.iris.structure.nativegen.NativeStructureOwnershipRecord;
import art.arcane.iris.structure.nativegen.IrisStructurePolicy;
@@ -454,11 +455,14 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
private Engine bindEngine(NativeWorld level) {
requireBindingAllowed();
// A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the
// history verification on every chunk call and fail the same way.
engineBinding.throwIfFailed(dimensionKey);
try {
requireGlobalStructureGeneration(
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
} catch (RuntimeException error) {
engineBinding.fail(error);
recordBindFailure(level, error);
throw error;
}
// Cache the owning level so hot paths never scan the level map to find themselves.
@@ -500,7 +504,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
engineBinding.complete(created);
return created;
} catch (Throwable error) {
engineBinding.fail(error);
recordBindFailure(level, error);
if (error instanceof RuntimeException runtimeException) {
throw runtimeException;
}
@@ -512,6 +516,25 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
}
}
private void recordBindFailure(NativeWorld level, Throwable error) {
engineBinding.fail(error);
for (String line : bindFailureNotice(level.name(), dimensionKey, error)) {
ModdedIrisLog.error(line);
}
}
static List<String> bindFailureNotice(String levelName, String dimensionKey, Throwable failure) {
return GenerationRefusalNotice.compose(
"Iris refused to generate " + levelName + " (generator " + dimensionKey + ")",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris does not generate this level and does not let vanilla or any other generator"
+ " write it; no chunks are written.",
"A startup level stops the server; a runtime dimension stays unloaded."
+ " Fix the cause, then start the server again."
));
}
private void requireCompletedShutdown(Engine current) {
if (current == null || !current.isClosing() || current.isClosed()) {
return;
@@ -18,12 +18,15 @@
package art.arcane.iris.modded;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@@ -36,6 +39,8 @@ public final class ModdedPrimaryWorldRouter {
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet();
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
private static int tickCounter = 0;
private static volatile String loginRefusal;
private static volatile String absentPrimary;
private ModdedPrimaryWorldRouter() {
}
@@ -43,6 +48,8 @@ public final class ModdedPrimaryWorldRouter {
public static void clear() {
routed.clear();
inFlight.clear();
loginRefusal = null;
absentPrimary = null;
}
/**
@@ -56,6 +63,20 @@ public final class ModdedPrimaryWorldRouter {
}
}
/**
* Disconnects a joining player while the configured primary world is missing.
*
* @return true when the player was refused
*/
public static boolean refuseIfUnavailable(NativeProtocolPlayer player) {
String refusal = loginRefusal;
if (refusal == null || player == null) {
return false;
}
player.disconnect(refusal);
return true;
}
public static void tick(NativeModdedServer server) {
if (server == null) {
return;
@@ -68,17 +89,23 @@ public final class ModdedPrimaryWorldRouter {
ModdedModConfig config = ModdedModConfig.get();
if (!config.routePlayersToPrimaryWorld()) {
loginRefusal = null;
return;
}
String primary = config.primaryWorld();
if (primary.isBlank()) {
loginRefusal = null;
return;
}
NativeWorld target = ModdedDimensionManager.level(server, primary);
if (target == null) {
if (ModdedStartup.dimensionsRestored()) {
judgeMissingPrimary(server, primary);
}
return;
}
loginRefusal = null;
NativeWorld overworld = server.overworld();
if (NativeDimensionRuntime.sameWorld(target, overworld)) {
return;
@@ -115,4 +142,62 @@ 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
* primary world this save expected and did not load refuses every player instead.
*/
private static void refuseLogins(NativeModdedServer server, String primary) {
String refusal = "Iris primary world '" + primary + "' is not loaded, so this server refuses players."
+ " An operator has to check the server console.";
if (loginRefusal == null) {
for (String line : GenerationRefusalNotice.compose(
"Iris refused player logins: primary world '" + primary + "' is not loaded",
List.of("routePlayersToPrimaryWorld sends players to '" + primary
+ "', which this save's dimension registry expects but did not load at startup."),
List.of(
"Players are disconnected instead of playing in the overworld, so no chunks are"
+ " written there.",
"Fix the dimension (see the Iris errors above) or clear primaryWorld in"
+ " config/irisworldgen/modded.json, then start the server again."
))) {
ModdedIrisLog.error(line);
}
}
loginRefusal = refusal;
List<NativeProtocolPlayer> online = new ArrayList<>();
// Disconnecting removes the player from the list being walked, so collect first.
server.forEachPlayer(online::add);
for (NativeProtocolPlayer player : online) {
player.disconnect(refusal);
}
}
}
@@ -123,11 +123,11 @@ public final class ModdedProtocolHandler {
}
public static void onPlayerJoin(NativeProtocolPlayer player) {
if (player == null) {
if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) {
return;
}
String sessionId = sessionId(player);
ModdedStartup.warnPackFailuresTo(player);
ModdedStartup.warnStartupFailuresTo(player);
IrisSessionRegistry current = registry;
ModdedProtocolTransport currentTransport = transport;
if (current == null || currentTransport == null) {
@@ -26,6 +26,7 @@ import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.command.ModdedPackCommands;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.safeguard.GenerationRefusalNotice;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
@@ -36,6 +37,7 @@ import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
@@ -43,6 +45,7 @@ public final class ModdedStartup {
private static final int COMPAT_BOOT_KEY_CAP = 3;
private static final AtomicBoolean PREPARED = new AtomicBoolean(false);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static final List<String> DIMENSION_FAILURES = new CopyOnWriteArrayList<>();
private ModdedStartup() {
}
@@ -50,6 +53,7 @@ public final class ModdedStartup {
public static void reset() {
PREPARED.set(false);
STARTED.set(false);
DIMENSION_FAILURES.clear();
}
public static void prepareForStartup() {
@@ -208,7 +212,7 @@ public final class ModdedStartup {
}
}
private static void reinjectPersistentDimensions(NativeModdedServer server) {
static void reinjectPersistentDimensions(NativeModdedServer server) {
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
ModdedDimensionRegistryStore.loadForStartup(server);
if (dimensions.isEmpty()) {
@@ -233,7 +237,7 @@ public final class ModdedStartup {
index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(),
System.currentTimeMillis() - dimensionStartedAt);
} catch (Throwable e) {
ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e);
reportDimensionFailure(dimension, e);
if (e instanceof OutOfMemoryError outOfMemory) {
throw outOfMemory;
}
@@ -243,6 +247,25 @@ public final class ModdedStartup {
injected, dimensions.size(), System.currentTimeMillis() - startedAt);
}
private static void reportDimensionFailure(ModdedDimensionRegistryStore.PersistentDimension dimension, Throwable failure) {
List<String> notice = GenerationRefusalNotice.compose(
"Iris refused to load dimension '" + dimension.id() + "' (pack=" + dimension.pack()
+ " dim=" + dimension.dimension() + " seed=" + dimension.seed() + ")",
GenerationRefusalNotice.causes(failure),
List.of(
"Iris does not generate this dimension and does not let vanilla or any other generator"
+ " write it; no chunks are written.",
"It stays unloaded. Fix the cause, then start the server again."
));
for (String line : notice) {
ModdedIrisLog.error(line);
}
ModdedIrisLog.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})",
dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), failure);
DIMENSION_FAILURES.add("Iris dimension '" + dimension.id() + "' did not load and refuses to generate: "
+ GenerationRefusalNotice.summary(failure));
}
private static long newestModificationMillis(Path root) {
long newest = 0L;
try (Stream<Path> walk = Files.walk(root)) {
@@ -261,13 +284,24 @@ public final class ModdedStartup {
}
/**
* SP-6: a pack excluded by validation is otherwise only visible in the console. Tell the operators who
* can actually act on it when they join.
* True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime
* dimension that is not loaded yet is still on its way.
*/
public static void warnPackFailuresTo(NativeProtocolPlayer player) {
public static boolean dimensionsRestored() {
return STARTED.get();
}
/**
* SP-6: a pack excluded by validation or a persistent dimension that did not load is otherwise only visible in
* the console. Tell the operators who can actually act on it when they join.
*/
public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
if (player == null || !player.isGameMaster()) {
return;
}
for (String failure : DIMENSION_FAILURES) {
player.sendMessage(failure);
}
for (Map.Entry<String, PackValidationResult> entry : PackValidationRegistry.snapshot().entrySet()) {
PackValidationResult result = entry.getValue();
if (result == null || result.isLoadable()) {