Modded adapters

This commit is contained in:
Brian Neumann-Fopiano
2026-08-15 15:58:26 -04:00
parent 8297e49ed2
commit ef2e1b8f58
29 changed files with 2647 additions and 710 deletions
@@ -10,6 +10,7 @@ import art.arcane.iris.engine.object.IrisImportedStructureControl;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.core.nms.MinecraftVersion;
import art.arcane.iris.core.nms.ServerShutdownBoundary;
import art.arcane.iris.core.nms.container.BiomeColor;
import art.arcane.iris.core.nms.container.Pair;
import art.arcane.iris.core.nms.container.BlockProperty;
@@ -1714,6 +1715,17 @@ public class NMSBinding implements INMSBinding {
return PaperLevelOverrides.createFromLiveLevelData(primaryLevelData);
}
@Override
public boolean awaitServerShutdownBoundary(long timeout, TimeUnit unit) {
MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer();
return ServerShutdownBoundary.await(
() -> server.hasFullyShutdown,
server.getRunningThread(),
timeout,
unit
);
}
@Override
public KMap<Material, List<BlockProperty>> getBlockProperties() {
KMap<Material, List<BlockProperty>> states = new KMap<>();
@@ -0,0 +1,28 @@
package art.arcane.iris.core.nms.v26_2_R1;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
public class NMSBindingShutdownBoundaryContractTest {
@Test
public void shutdownBoundaryUsesPaperFullyShutdownStateAndServerThread() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource")));
String boundary = section(source, "public boolean awaitServerShutdownBoundary", "public KMap<Material, List<BlockProperty>> getBlockProperties");
assertTrue(boundary.contains("ServerShutdownBoundary.await("));
assertTrue(boundary.contains("server.hasFullyShutdown"));
assertTrue(boundary.contains("server.getRunningThread()"));
}
private static String section(String source, String startMarker, String endMarker) {
int start = source.indexOf(startMarker);
int end = source.indexOf(endMarker, start);
assertTrue("Missing source section starting with " + startMarker, start >= 0);
assertTrue("Missing source section ending with " + endMarker, end > start);
return source.substring(start, end);
}
}
+2
View File
@@ -67,6 +67,8 @@ tasks.named('test').configure {
systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath)
systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath)
systemProperty('iris.engineSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java').absolutePath)
systemProperty('iris.jigsawStudioSvcSource', rootProject.file('core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java').absolutePath)
systemProperty('iris.studioSvcSource', rootProject.file('core/src/main/java/art/arcane/iris/core/service/StudioSVC.java').absolutePath)
systemProperty('iris.terrainSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java').absolutePath)
systemProperty('iris.apiEventSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java').absolutePath)
systemProperty('iris.worldInfoFactorySource', file('src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java').absolutePath)
@@ -75,7 +75,6 @@ import art.arcane.iris.core.service.WandSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.EnginePanic;
import art.arcane.iris.engine.framework.BlockEditAccess;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.PreservationRegistry;
import art.arcane.iris.engine.framework.TreeBlockMaterial;
import art.arcane.iris.engine.object.IrisCompat;
@@ -148,6 +147,8 @@ import java.util.regex.Pattern;
@SuppressWarnings("CanBeFinal")
public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private static final long SERVER_SHUTDOWN_BOUNDARY_TIMEOUT_SECONDS = 300L;
private static final long SERVER_STOP_PREGEN_TIMEOUT_MILLIS = 30000L;
private static final Queue<Runnable> syncJobs = new ShurikenQueue<>();
static {
@@ -175,8 +176,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private static final Object TEARDOWN_LOCK = new Object();
private final AtomicBoolean alreadyDrained = new AtomicBoolean(false);
private final AtomicBoolean postStopFinisherStarted = new AtomicBoolean(false);
private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false);
private final AtomicBoolean servicesDisabled = new AtomicBoolean(false);
private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false);
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
private volatile PlaceholderRegistration papiRegistration;
private volatile IrisPapiListener papiListener;
private volatile IrisPapiState papiState;
@@ -184,11 +188,13 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
// Copy-on-write: mutated on the main thread during enable() and iterated by the JVM
// shutdown-hook thread during teardown; a plain list would CME and abort the teardown.
private final List<IrisService> enabledServices = new CopyOnWriteArrayList<>();
private final List<PlatformChunkGenerator> deferredShutdownGenerators = new CopyOnWriteArrayList<>();
private final IrisWorldGeneratorResolver generatorResolver = new IrisWorldGeneratorResolver(this);
private final BukkitWorldReconciler worldReconciler = new BukkitWorldReconciler(this);
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile Thread serverLifecycleThread;
public static VolmitSender getSender() {
if (sender == null) {
@@ -530,8 +536,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return false;
}
alreadyDrained.set(false);
postStopFinisherStarted.set(false);
serverStopTeardownDeferred.set(false);
servicesDisabled.set(false);
sharedRuntimeClosed.set(false);
terminalCleanupCompleted.set(false);
deferredShutdownGenerators.clear();
MultiBurst.burst.reopen();
MultiBurst.ioBurst.reopen();
IrisLanguage.initialize();
@@ -669,7 +679,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void addShutdownHook() {
removeShutdownHook();
shutdownHook = new Thread(() -> teardownRuntime("shutdown-hook", 30L), "Iris-ShutdownHook");
serverLifecycleThread = Thread.currentThread();
shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook");
try {
Runtime.getRuntime().addShutdownHook(shutdownHook);
} catch (IllegalStateException ex) {
@@ -756,9 +767,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void onDisable() {
teardownPapi();
teardownRuntime("onDisable", 30L);
removeShutdownHook();
J.attempt(() -> INMS.get().uninjectBukkit());
boolean serverStopping = IrisToolbelt.isServerStopping();
if (serverStopping) {
quiesceRuntimeForServerShutdown("onDisable");
startPostStopFinisher();
} else {
teardownRuntime("onDisable", 30L);
removeShutdownHook();
}
if (BukkitPlatform.hasHud()) {
BukkitPlatform.hudSlots().shutdown();
BukkitPlatform.hudLanes().shutdown();
@@ -767,15 +783,22 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
configHotloadEngine.clear();
configHotloadEngine = null;
}
runPostShutdown();
// super.onDisable() cancels plugin tasks and unregisters every listener.
super.onDisable();
IrisPlatforms.unbind();
if (!serverStopping) {
finishTerminalCleanup();
}
}
@Override
public void onPreUnload(ReloadAware.PreUnloadReason reason) {
teardownPapi();
if (IrisToolbelt.isServerStopping()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher();
Iris.info("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
return;
}
if (alreadyDrained.get()) {
Iris.info("Pre-unload hook skipped; Iris already drained.");
return;
@@ -829,14 +852,131 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
}
private void drainWorldGenerators(String reason, long timeoutSeconds) {
List<World> irisWorlds = new ArrayList<>();
for (World world : Bukkit.getWorlds()) {
if (IrisToolbelt.access(world) != null) {
irisWorlds.add(world);
private void quiesceRuntimeForServerShutdown(String reason) {
serverStopTeardownDeferred.set(true);
JigsawStudioService jigsawStudioService = IrisServices.getOrNull(JigsawStudioService.class);
if (jigsawStudioService != null) {
try {
jigsawStudioService.quiesceForServerShutdown();
} catch (Throwable e) {
Iris.reportError("Failed to quiesce Jigsaw Studio before server shutdown.", e);
}
}
if (irisWorlds.isEmpty()) {
StudioSVC studioService = IrisServices.getOrNull(StudioSVC.class);
if (studioService != null) {
studioService.quiesceDownloadsForShutdown();
}
try {
PregeneratorJob.shutdownAndWait(SERVER_STOP_PREGEN_TIMEOUT_MILLIS);
} catch (Throwable e) {
Iris.reportError("Failed to quiesce the Iris pregenerator before server shutdown.", e);
}
for (World world : Bukkit.getWorlds()) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null) {
continue;
}
IrisToolbelt.beginWorldMaintenance(world, reason, true);
if (!deferredShutdownGenerators.contains(generator)) {
deferredShutdownGenerators.add(generator);
}
generator.quiesceForServerShutdown();
}
}
private void startPostStopFinisher() {
if (!postStopFinisherStarted.compareAndSet(false, true)) {
return;
}
Thread activeServerThread = serverLifecycleThread;
if (activeServerThread == null) {
Iris.warn("Iris could not start its post-stop runtime finisher because the server lifecycle thread is unavailable.");
return;
}
Thread finisher = new Thread(() -> {
if (!awaitServerThreadTermination(activeServerThread)) {
return;
}
finishDeferredRuntimeTeardown("post-server-stop", 30L);
}, "Iris-PostStop-Finisher");
finisher.setDaemon(false);
finisher.start();
}
static boolean awaitServerThreadTermination(Thread serverThread) {
if (serverThread == null || serverThread == Thread.currentThread()) {
return false;
}
try {
serverThread.join();
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
Iris.reportError("Iris post-stop runtime finisher was interrupted.", e);
return false;
}
}
private void runShutdownHook() {
if (!awaitServerShutdownBoundary()) {
Iris.warn("Iris skipped JVM-hook runtime teardown because Paper did not reach its post-world-close boundary.");
return;
}
finishDeferredRuntimeTeardown("shutdown-hook", 30L);
}
private boolean awaitServerShutdownBoundary() {
if (!INMS.isBound()) {
return true;
}
try {
return INMS.get().awaitServerShutdownBoundary(
SERVER_SHUTDOWN_BOUNDARY_TIMEOUT_SECONDS,
TimeUnit.SECONDS
);
} catch (Throwable e) {
Iris.reportError("Failed to await Paper's post-world-close shutdown boundary.", e);
return false;
}
}
private void finishDeferredRuntimeTeardown(String reason, long timeoutSeconds) {
teardownRuntime(reason, timeoutSeconds);
finishTerminalCleanup();
}
private void finishTerminalCleanup() {
if (!terminalCleanupCompleted.compareAndSet(false, true)) {
return;
}
J.attempt(() -> INMS.get().uninjectBukkit());
try {
runPostShutdown();
} catch (Throwable e) {
Iris.reportError("Failed to run Iris post-shutdown cleanup.", e);
} finally {
IrisPlatforms.unbind();
}
}
private void drainWorldGenerators(String reason, long timeoutSeconds) {
List<World> irisWorlds = new ArrayList<>();
List<PlatformChunkGenerator> generators = new ArrayList<>();
if (serverStopTeardownDeferred.get()) {
generators.addAll(deferredShutdownGenerators);
} else {
for (World world : Bukkit.getWorlds()) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator != null) {
irisWorlds.add(world);
generators.add(generator);
}
}
}
if (generators.isEmpty()) {
Iris.info("No Iris worlds to freeze.");
return;
}
@@ -848,17 +988,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
J.attempt(PregeneratorJob::shutdownInstance);
List<CompletableFuture<Void>> closes = new ArrayList<>();
for (World world : irisWorlds) {
PlatformChunkGenerator gen = IrisToolbelt.access(world);
if (gen == null) continue;
Engine engine = gen.getEngine();
if (engine != null) {
J.attempt(() -> engine.getMantle().saveAllNow());
}
for (PlatformChunkGenerator generator : generators) {
try {
closes.add(gen.closeAsync());
closes.add(generator.closeAsync());
} catch (Throwable t) {
Iris.reportError(t);
}
@@ -45,6 +45,7 @@ import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
@@ -53,6 +54,7 @@ import java.util.function.Supplier;
* Bukkit plugin entry points delegate to.
*/
public final class IrisWorldGeneratorResolver {
private static final int VALIDATION_STABILITY_ATTEMPTS = 2;
private static final Object SNAPSHOT_VALIDATION_LOCK = new Object();
private final VolmitPlugin plugin;
@@ -67,15 +69,16 @@ public final class IrisWorldGeneratorResolver {
PackValidationRegistry.clear();
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
String contentFingerprint = "";
ServerConfigurator.PackContentSnapshot contentSnapshot =
new ServerConfigurator.PackContentSnapshot("", Map.of());
String contextFingerprint = "";
Optional<List<PackValidationResult>> cached = Optional.empty();
try {
contentFingerprint = PackValidationCache.contentFingerprint(packsRoot);
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint();
cached = PackValidationCache.load(
cacheFile,
contentFingerprint,
contentSnapshot.content(),
contextFingerprint,
packNames);
} catch (RuntimeException exception) {
@@ -88,33 +91,31 @@ public final class IrisWorldGeneratorResolver {
Iris.info("Reused persisted validation for " + results.size()
+ " unchanged Iris pack(s); full pack parsing was skipped.");
} else {
results = new ArrayList<>(packDirs.size());
for (File packDir : packDirs) {
FreshValidation validation = validateStablePacks(packsRoot, packDirs, contentSnapshot);
packDirs = validation.packDirs();
contentSnapshot = validation.contentSnapshot();
results = validation.results();
if (validation.stable()) {
try {
results.add(PackValidator.validate(packDir));
} catch (Throwable exception) {
Iris.reportError("Pack validation failed for '" + packDir.getName() + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
}
results.add(new PackValidationResult(
packDir.getName(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis()));
PackValidationCache.save(
cacheFile,
contentSnapshot.content(),
contextFingerprint,
results);
} catch (IOException exception) {
Iris.reportError("Could not persist Iris pack-validation results", exception);
}
}
try {
PackValidationCache.save(cacheFile, contentFingerprint, contextFingerprint, results);
} catch (IOException exception) {
Iris.reportError("Could not persist Iris pack-validation results", exception);
}
}
Map<String, String> packFingerprints = contentSnapshot.packContents();
for (PackValidationResult result : results) {
PackValidationRegistry.publish(result);
String packFingerprint = packFingerprints.get(result.getPackName());
File packDirectory = PackDirectoryResolver.resolveExisting(packsRoot, result.getPackName());
if (packDirectory != null && packFingerprint != null && !packFingerprint.isBlank()) {
PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint);
}
if (!result.isLoadable()) {
Iris.error("Pack '" + result.getPackName()
+ "' FAILED validation - world and Studio creation with this pack will be refused. Reasons:");
@@ -134,6 +135,67 @@ public final class IrisWorldGeneratorResolver {
IrisStartupValidation.markPacksReady();
}
private static FreshValidation validateStablePacks(
File packsRoot,
List<File> initialPackDirs,
ServerConfigurator.PackContentSnapshot initialSnapshot
) {
List<File> packDirs = initialPackDirs;
ServerConfigurator.PackContentSnapshot before = initialSnapshot;
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
List<PackValidationResult> results = validatePacks(packDirs);
ServerConfigurator.PackContentSnapshot after;
try {
after = ServerConfigurator.computePackContentSnapshot(packsRoot);
} catch (RuntimeException exception) {
Iris.reportError("Could not verify Iris pack bytes after validation", exception);
after = new ServerConfigurator.PackContentSnapshot("", Map.of());
}
List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
if (!before.content().isBlank()
&& before.content().equals(after.content())
&& packNames.equals(afterPackNames)) {
return new FreshValidation(afterPackDirs, after, results, true);
}
packDirs = afterPackDirs;
before = after;
}
Iris.error("Iris pack files kept changing during validation; validation was refused until writes stop.");
List<PackValidationResult> failures = new ArrayList<>(packDirs.size());
for (File packDir : packDirs) {
failures.add(new PackValidationResult(
packDir.getName(),
List.of("Pack files changed while validation was in progress; retry after writes stop."),
List.of(),
System.currentTimeMillis()));
}
return new FreshValidation(packDirs, before, failures, false);
}
private static List<PackValidationResult> validatePacks(List<File> packDirs) {
List<PackValidationResult> results = new ArrayList<>(packDirs.size());
for (File packDir : packDirs) {
try {
results.add(PackValidator.validate(packDir));
} catch (Throwable exception) {
Iris.reportError("Pack validation failed for '" + packDir.getName() + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
}
results.add(new PackValidationResult(
packDir.getName(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis()));
}
}
return results;
}
static PackValidationResult requireSnapshotLoadable(File packRoot) {
Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize();
PackValidationResult result = PackValidationRegistry.get(normalizedRoot);
@@ -141,22 +203,26 @@ public final class IrisWorldGeneratorResolver {
synchronized (SNAPSHOT_VALIDATION_LOCK) {
result = PackValidationRegistry.get(normalizedRoot);
if (result == null) {
try {
result = PackValidator.validate(normalizedRoot.toFile());
} catch (Throwable exception) {
Iris.reportError("Snapshot pack validation failed for '" + normalizedRoot + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
PackValidationRegistry.ValidationTicket ticket =
PackValidationRegistry.tryBeginValidation(normalizedRoot);
if (ticket != null) {
try {
result = PackValidator.validate(normalizedRoot.toFile());
} catch (Throwable exception) {
Iris.reportError("Snapshot pack validation failed for '" + normalizedRoot + "'", exception);
String detail = exception.getMessage();
if (detail == null || detail.isBlank()) {
detail = exception.getClass().getSimpleName();
}
result = new PackValidationResult(
normalizedRoot.getFileName().toString(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis());
}
result = new PackValidationResult(
normalizedRoot.getFileName().toString(),
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
+ ": " + detail),
List.of(),
System.currentTimeMillis());
PackValidationRegistry.publishIfCurrent(ticket, result);
}
PackValidationRegistry.publish(normalizedRoot, result);
}
}
}
@@ -265,4 +331,12 @@ public final class IrisWorldGeneratorResolver {
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
}
private record FreshValidation(
List<File> packDirs,
ServerConfigurator.PackContentSnapshot contentSnapshot,
List<PackValidationResult> results,
boolean stable
) {
}
}
@@ -0,0 +1,166 @@
package art.arcane.iris;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisShutdownOrderingTest {
@Test
public void drainWorldGenerators_closesGeneratorsWithoutEagerMantleSave() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String drain = section(source, "private void drainWorldGenerators", "private void setupPapi");
assertTrue("Shutdown must close each Iris generator", drain.contains("generator.closeAsync()"));
assertFalse("Shutdown must not close Mantle plates before generation drains", drain.contains("saveAllNow()"));
}
@Test
public void serverStop_defersRuntimeTeardownUntilPaperShutdownBoundary() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String onDisable = section(source, "public void onDisable()", "public void onPreUnload");
String quiesce = section(source, "private void quiesceRuntimeForServerShutdown", "private void startPostStopFinisher");
String shutdownHook = section(source, "private void runShutdownHook()", "private boolean awaitServerShutdownBoundary");
String finisher = section(source, "private void startPostStopFinisher()", "static boolean awaitServerThreadTermination");
String serverJoin = section(source, "static boolean awaitServerThreadTermination", "private void runShutdownHook");
String generatorSource = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource")));
String generatorQuiesce = section(generatorSource,
"public void quiesceForServerShutdown()", "public boolean isStudio()");
assertOrdered(onDisable,
"if (serverStopping)",
"quiesceRuntimeForServerShutdown(\"onDisable\")",
"startPostStopFinisher()",
"else",
"teardownRuntime(\"onDisable\", 30L)");
assertOrdered(shutdownHook,
"awaitServerShutdownBoundary()",
"finishDeferredRuntimeTeardown(\"shutdown-hook\", 30L)");
assertOrdered(finisher,
"finisher.setDaemon(false)",
"finisher.start()");
assertOrdered(finisher,
"awaitServerThreadTermination(activeServerThread)",
"finishDeferredRuntimeTeardown(\"post-server-stop\", 30L)");
assertOrdered(serverJoin,
"serverThread == Thread.currentThread()",
"serverThread.join()");
assertTrue("Server-stop quiescence must leave queued Paper generation admitted",
quiesce.contains("generator.quiesceForServerShutdown()"));
assertOrdered(quiesce,
"jigsawStudioService.quiesceForServerShutdown()",
"PregeneratorJob.shutdownAndWait",
"generator.quiesceForServerShutdown()");
assertOrdered(onDisable,
"quiesceRuntimeForServerShutdown(\"onDisable\")",
"super.onDisable()");
assertFalse("Server-stop quiescence must not begin generator close before Paper's boundary",
generatorQuiesce.contains("closing = true"));
assertFalse("Server-stop quiescence must not dispatch generator close before Paper's boundary",
generatorQuiesce.contains("closeAsync()"));
}
@Test
public void deferredTeardown_keepsGeneratorClosingServicesBehindGeneratorDrain() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String engineService = Files.readString(Path.of(System.getProperty("iris.engineSvcSource")));
String studioService = Files.readString(Path.of(System.getProperty("iris.studioSvcSource")));
String generatorSource = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource")));
String teardown = section(source, "private void teardownRuntime", "private void quiesceRuntimeForServerShutdown");
String engineDisable = section(engineService, "public void onDisable()", "public void engineStatus");
String studioDisable = section(studioService, "public void onDisable()", "public IrisDimension installIntoWorld");
String generatorClose = section(generatorSource,
"public void close()", "public CompletableFuture<Void> closeAsync()");
String generatorCloseAsync = section(generatorSource,
"public CompletableFuture<Void> closeAsync()", "public void quiesceForServerShutdown()");
assertOrdered(teardown,
"drainWorldGenerators(reason, timeoutSeconds)",
"service.onDisable()",
"MultiBurst.burst::close",
"MultiBurst.ioBurst::close");
assertTrue("Engine service must retain its generator close behind deferred service teardown",
engineDisable.contains("startClose("));
assertTrue("Studio service must retain its generator close behind deferred service teardown",
studioDisable.contains("generator.close()"));
assertTrue("Service-level generator close must delegate to the shared idempotent close future",
generatorClose.contains("closeAsync()"));
assertTrue("Repeated post-boundary closes must return the already-published close future",
generatorCloseAsync.contains("return existing;"));
assertFalse("A completed generator close must never be re-dispatched",
generatorCloseAsync.contains("!existing.isDone()"));
}
@Test
public void preUnload_doesNotDrainGeneratorsDuringServerStop() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String preUnload = section(source, "public void onPreUnload", "private void drainOnce");
assertOrdered(preUnload,
"IrisToolbelt.isServerStopping()",
"quiesceRuntimeForServerShutdown",
"startPostStopFinisher()",
"return;",
"drainOnce(");
}
@Test
public void jigsawStudio_quiescesOnceBeforeDeferredServiceTeardown() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.jigsawStudioSvcSource")));
String enable = section(source, "public void onEnable()", "public void onDisable()");
String disable = section(source, "public void onDisable()", "public void quiesceForServerShutdown()");
String quiesce = section(source, "public void quiesceForServerShutdown()", "public void register(");
String register = section(source, "public void register(", "public void activationCommitted(");
String activation = section(source, "public void activationCommitted(", "public void markChunkGenerated(");
String chunkGenerated = section(source, "public void markChunkGenerated(", "private void markChunkAvailable(");
assertOrdered(enable, "disableStarted.set(false)", "enabled = true");
assertTrue("Deferred service teardown must reuse the idempotent early disable",
disable.contains("quiesceForServerShutdown();"));
assertOrdered(quiesce,
"disableStarted.compareAndSet(false, true)",
"finalizeAllJigsawTileWatches()",
"drainAutosavesBeforeDisable()",
"enabled = false",
"activeMenuController.closeAll()",
"previewRenderer.removeAll()",
"studios.clear()");
assertTrue("Registration must reject work after shutdown begins",
occurrences(register, "!enabled || disableStarted.get()") >= 2);
assertTrue("Activation must reject work after shutdown begins",
activation.contains("!enabled || disableStarted.get()"));
assertTrue("Chunk-generation callbacks must reject work after shutdown begins",
occurrences(chunkGenerated, "!enabled || disableStarted.get()") >= 2);
}
private static String section(String source, String startMarker, String endMarker) {
int start = source.indexOf(startMarker);
int end = source.indexOf(endMarker, start);
assertTrue("Missing source section starting with " + startMarker, start >= 0);
assertTrue("Missing source section ending with " + endMarker, end > start);
return source.substring(start, end);
}
private static void assertOrdered(String source, String... markers) {
int previous = -1;
for (String marker : markers) {
int current = source.indexOf(marker);
assertTrue("Missing source marker " + marker, current >= 0);
assertTrue("Source marker is out of order: " + marker, current > previous);
previous = current;
}
}
private static int occurrences(String source, String marker) {
int count = 0;
int offset = 0;
while ((offset = source.indexOf(marker, offset)) >= 0) {
count++;
offset += marker.length();
}
return count;
}
}
@@ -64,6 +64,23 @@ public class IrisWorldGeneratorResolverTest {
assertFalse(invalid.getBlockingErrors().toString(), invalid.isLoadable());
}
@Test
public void startupValidationPublishesFingerprintBoundExactRootResults() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java"));
int validateAll = source.indexOf("public void validateAllPacks()");
int snapshot = source.indexOf("ServerConfigurator.computePackContentSnapshot(packsRoot)", validateAll);
int perPackFingerprint = source.indexOf("contentSnapshot.packContents()", snapshot);
int exactRootPublish = source.indexOf(
"PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint)",
perPackFingerprint);
assertTrue(validateAll >= 0);
assertTrue(snapshot > validateAll);
assertTrue(perPackFingerprint > snapshot);
assertTrue(exactRootPublish > perPackFingerprint);
}
@Test
public void paperStartupAliasResolvesToCanonicalRuntimeKey() {
assertEquals(
@@ -17,6 +17,7 @@ public class BukkitEngineLifecycleContractTest {
assertBefore(closeAsync, "closeFuture.compareAndSet(null, future)", "withExclusiveControlFuture(");
assertTrue(closeAsync.contains("while (!closeFuture.compareAndSet(null, future))"));
assertTrue(closeAsync.contains("return existing;"));
assertTrue(closeAsync.contains("operation.whenComplete("));
assertFalse(closeAsync.contains("!existing.isDone()"));