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

@@ -372,8 +372,7 @@ public final class BukkitWorldConfiguration {
+ "\" is not a string.");
}
String configuredGenerator = generator.trim();
if (!configuredGenerator.equalsIgnoreCase("Iris")
&& !configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5)) {
if (!isIrisGenerator(configuredGenerator)) {
continue;
}
@@ -395,6 +394,35 @@ public final class BukkitWorldConfiguration {
return candidates;
}
/**
* True when any bukkit.yml world names Iris as its generator, vanilla slots included. CraftServer asks a plugin
* for a generator only for these worlds, so they are the ones a disabled Iris would hand to vanilla.
*/
public static boolean configuresIrisGenerator(File configurationFile) throws IOException {
Path configurationPath = Objects.requireNonNull(configurationFile, "configurationFile").toPath();
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
synchronized (MUTATION_LOCK) {
ConfigurationSection worlds = load(configurationFile).getConfigurationSection("worlds");
if (worlds == null) {
return false;
}
for (String worldName : worlds.getKeys(false)) {
if (worlds.get(worldName + ".generator") instanceof String generator
&& isIrisGenerator(generator.trim())) {
return true;
}
}
return false;
}
}
private static boolean isIrisGenerator(String configuredGenerator) {
return configuredGenerator.equalsIgnoreCase("Iris")
|| configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5);
}
public static GeneratorReplacement replaceIfMatching(
File configurationFile,
String worldName,
@@ -61,7 +61,7 @@ public final class MissingWorldStorageLog {
}
emit(new String[]{
IrisLogging.format("Iris world %s has an empty world folder at %s: no pack snapshot, no region"
+ " data; it will generate as vanilla terrain.", worldName, worldStorage),
+ " data; Iris refuses to generate it and no other generator gets it.", worldName, worldStorage),
IrisLogging.format("Delete that folder, or drop the world with /iris remove world=%s delete=true.",
worldName)
});
@@ -0,0 +1,58 @@
package art.arcane.iris.world.safeguard;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* The framed console block Iris prints when it refuses to generate a world. Every platform prints the same shape
* so a refusal is found the same way in any server log.
*/
public final class GenerationRefusalNotice {
private static final String RULE = "=".repeat(78);
private GenerationRefusalNotice() {
}
public static List<String> compose(String headline, List<String> causes, List<String> consequences) {
List<String> lines = new ArrayList<>(causes.size() + consequences.size() + 3);
lines.add(RULE);
lines.add(Objects.requireNonNull(headline, "headline"));
for (int index = 0; index < causes.size(); index++) {
lines.add((index == 0 ? "Cause: " : "Caused by: ") + causes.get(index));
}
lines.addAll(consequences);
lines.add(RULE);
return List.copyOf(lines);
}
/**
* Every distinct message in the cause chain, outermost first. A wrapper whose message only repeats its cause
* (CompletionException, a rethrow with the same text) adds nothing and is skipped.
*/
public static List<String> causes(Throwable failure) {
List<String> causes = new ArrayList<>();
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
Throwable current = failure;
while (current != null && seen.add(current)) {
Throwable cause = current.getCause();
String message = current.getMessage();
boolean repeatsCause = cause != null && cause.toString().equals(message);
if (!repeatsCause) {
String text = message == null || message.isBlank() ? current.getClass().getSimpleName() : message.trim();
if (causes.isEmpty() || !causes.getLast().equals(text)) {
causes.add(text);
}
}
current = cause;
}
return List.copyOf(causes);
}
public static String summary(Throwable failure) {
return String.join("; ", causes(failure));
}
}