Stabilized shutdown and Studio

- Shutdown protection initializes before enable and drains inline when retention fails
- Cleanup preserves class loader retention and closes native providers in order
- Error reports retain context and handle empty stack traces
- Studio maps retain partial coverage and suspend hidden views
- Hydrology previews refresh when plans and rasters complete
- Terrain scratch hashing spreads lattice samples across cache slots
- Plugin and probe artifacts include LWJGL libraries and native resources
- Regression tests cover shutdown, error reporting, map progress, and terrain scratch
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-26 23:29:21 -04:00
1 parent f64a7c6753
commit 3dc958edcf
31 files changed
+1973 -217

No files matched your search

@@ -57,6 +57,7 @@ import art.arcane.iris.localization.IrisLanguage;
import art.arcane.volmlib.util.director.help.DirectorMiniMenu;
import art.arcane.volmlib.util.localization.BukkitLanguageSwitcher;
import art.arcane.iris.integration.MultiverseCoreLink;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.iris.studio.view.BukkitGuiHost;
@@ -201,10 +202,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final AtomicBoolean postStopFinisherStarted = new AtomicBoolean(false);
private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false);
private final AtomicBoolean servicesDisabled = new AtomicBoolean(false);
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false);
private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false);
private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false);
private final AtomicBoolean runtimeTeardownFailed = new AtomicBoolean(false);
private volatile boolean generatorDrainCompleted;
private volatile boolean enableFailed;
private volatile PlaceholderRegistration papiRegistration;
@@ -225,6 +226,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private BukkitDebugDump debugDump;
private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile ServerShutdownBoundary serverShutdownBoundary;
private boolean shutdownProtectionInitialized;
public static VolmitSender getSender() {
VolmitSender current = sender;
@@ -474,10 +476,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
if (debug) {
String n = e.getClass().getCanonicalName() + "-" + e.getStackTrace()[0].getClassName() + "-" + e.getStackTrace()[0].getLineNumber();
String n = e.getClass().getName();
StackTraceElement[] trace = e.getStackTrace();
if (trace.length > 0) {
n += "-" + trace[0].getClassName() + "-" + trace[0].getLineNumber();
}
if (e.getCause() != null) {
n += "-" + e.getCause().getStackTrace()[0].getClassName() + "-" + e.getCause().getStackTrace()[0].getLineNumber();
StackTraceElement[] causeTrace = e.getCause().getStackTrace();
n += "-" + (causeTrace.length == 0 ? e.getCause().getClass().getName()
: causeTrace[0].getClassName() + "-" + causeTrace[0].getLineNumber());
}
File f = instance.getDataFile("debug", "caught-exceptions", n + ".txt");
@@ -627,10 +635,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
postStopFinisherStarted.set(false);
serverStopTeardownDeferred.set(false);
servicesDisabled.set(false);
runtimeTeardownFailed.set(false);
sharedRuntimeClosed.set(false);
startupBoundaryRestart.set(false);
terminalCleanupCompleted.set(false);
runtimeTeardownFailed.set(false);
generatorDrainCompleted = false;
deferredShutdownGenerators.clear();
MultiBurst.burst.reopen();
@@ -911,6 +919,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
public void onEnable() {
enableFailed = false;
shutdownProtectionInitialized = false;
IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
try {
@@ -922,13 +931,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
refuseUnsupportedServer();
return;
}
initializeShutdownProtection();
enable();
} catch (Throwable failure) {
enableFailed = true;
if (!FailedEnableLock.engage(failure, FailedEnableLock.irisWorldsPresent(),
generatorResolver.refusals().startupWorldsCreated())) {
throw failure;
}
enableFailed = true;
return;
}
BukkitGuiHost.install();
@@ -946,6 +956,17 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
reportLockedRuntime();
}
private void initializeShutdownProtection() {
if (!INMS.get().supportsIrisWorldGeneration()) {
return;
}
if (!Agent.install()) {
throw new IllegalStateException("Iris Java agent is unavailable; shutdown protection cannot be installed.");
}
INMS.get().initializeLifecycle(getClass().getClassLoader());
shutdownProtectionInitialized = true;
}
private void refuseUnsupportedServer() {
Throwable bindFailure = INMS.bindFailure();
IllegalStateException failure = new IllegalStateException("Iris cannot start: " + (bindFailure == null
@@ -1008,7 +1029,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisLanguage.shutdown();
teardownPapi();
boolean deferToServerStop = FailedEnableLock.defersTeardownToServerStop(enableFailed, IrisToolbelt.isServerStopping())
&& INMS.get().deferPluginClassLoaderClose();
&& tryDeferPluginClassLoaderClose();
boolean restartingAtStartupBoundary = startupBoundaryRestart.get();
if (restartingAtStartupBoundary) {
teardownRuntime("startup-boundary-restart", 30L);
@@ -1040,7 +1061,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
preservePendingBlockDrops();
papiLifecycleActive = false;
teardownPapi();
if (IrisToolbelt.isServerStopping() && INMS.get().deferPluginClassLoaderClose()) {
if (IrisToolbelt.isServerStopping() && tryDeferPluginClassLoaderClose()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher();
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
@@ -1055,6 +1076,19 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return CompletableFuture.completedFuture(null);
}
private boolean tryDeferPluginClassLoaderClose() {
if (!shutdownProtectionInitialized) {
return false;
}
try {
INMS.get().deferPluginClassLoaderClose();
return true;
} catch (RuntimeException | LinkageError failure) {
Iris.reportError("Failed to retain the Iris plugin class loader; draining shutdown inline.", failure);
return false;
}
}
/**
* Drains the world generators exactly once. Serialized against the JVM shutdown hook so a
* second caller cannot rip the pools or services out from under an in-flight drain.
@@ -1101,19 +1135,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
try {
MultiBurst.burst.close();
} catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris generation workers.", failure);
}
try {
MultiBurst.hydrology.close();
} catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris hydrology workers.", failure);
}
try {
MultiBurst.ioBurst.close();
} catch (Throwable failure) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to close Iris I/O workers.", failure);
}
clearQueues();
@@ -1229,30 +1260,37 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
if (!terminalCleanupCompleted.compareAndSet(false, true)) {
return;
}
J.attempt(() -> INMS.get().uninjectBukkit());
try {
if (INMS.isBound()) {
INMS.get().uninjectBukkit();
}
} catch (Throwable failure) {
Iris.reportError("Failed to remove Iris world-generation hooks.", failure);
}
try {
runPostShutdown();
} catch (Throwable e) {
runtimeTeardownFailed.set(true);
Iris.reportError("Failed to run Iris post-shutdown cleanup.", e);
} finally {
if (serverStopTeardownDeferred.get() && hasActiveShutdownResources()) {
runtimeTeardownFailed.set(true);
SHUTDOWN_ERRORS.println("[Iris] Owned background work remains active after cleanup; retaining the plugin class loader until JVM exit.");
}
boolean activeResources = serverStopTeardownDeferred.get()
&& (runtimeTeardownFailed.get() || hasActiveShutdownResources());
IrisPlatforms.unbind();
if (serverStopTeardownDeferred.get() && runtimeTeardownFailed.get()) {
SHUTDOWN_ERRORS.println("[Iris] Runtime cleanup failed; retaining the plugin class loader until JVM exit.");
BukkitPlatform.releaseHost();
if (activeResources) {
SHUTDOWN_ERRORS.println("[Iris] Owned background work has not completed cleanup; retaining the plugin class loader until JVM exit.");
} else {
try {
INMS.get().releasePluginClassLoaderClose();
SlimJar.closeNativeProviders();
if (shutdownProtectionInitialized) {
INMS.get().releasePluginClassLoaderClose();
} else {
SlimJar.closeNativeProviders();
}
} catch (Throwable failure) {
SHUTDOWN_ERRORS.println("[Iris] Failed to release the plugin class loader after runtime cleanup.");
failure.printStackTrace(SHUTDOWN_ERRORS);
}
}
BukkitPlatform.releaseHost();
}
}
@@ -148,6 +148,9 @@ public class FailedEnableLockTest {
try (EnableFixture fixture = new EnableFixture(false)) {
IllegalStateException failure = assertThrows(IllegalStateException.class, plugin::onEnable);
assertTrue(failure.getMessage(), failure.getMessage().contains("already bound"));
Field failed = Iris.class.getDeclaredField("enableFailed");
failed.setAccessible(true);
assertTrue(failed.getBoolean(plugin));
}
}
@@ -0,0 +1,80 @@
package art.arcane.iris;
import art.arcane.iris.configuration.IrisSettings;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.PrintStream;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class IrisErrorReportingTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void debugReportingPreservesStacklessFailures() throws Exception {
ArrayIndexOutOfBoundsException failure = new ArrayIndexOutOfBoundsException("raster index");
failure.setStackTrace(new StackTraceElement[0]);
String output = reportWithDebug(failure);
assertEquals(1L, output.lines().filter(line -> line.contains("raster index")).count());
}
@Test
public void debugReportingPreservesFailureWithStacklessCause() throws Exception {
ArrayIndexOutOfBoundsException cause = new ArrayIndexOutOfBoundsException("raster index");
cause.setStackTrace(new StackTraceElement[0]);
IllegalStateException failure = new IllegalStateException("Hydrology failed", cause);
String output = reportWithDebug(failure);
assertEquals(1L, output.lines().filter(line -> line.contains("Hydrology failed")).count());
assertTrue(output.contains("Caused by: java.lang.ArrayIndexOutOfBoundsException: raster index"));
assertTrue(output.contains("IrisErrorReportingTest.debugReportingPreservesFailureWithStacklessCause"));
}
private String reportWithDebug(Throwable failure) throws Exception {
Iris previousPlugin = Iris.instance;
IrisSettings previousSettings = IrisSettings.settings;
PrintStream previousError = System.err;
Iris plugin = mock(Iris.class);
Field deferred = Iris.class.getDeclaredField("serverStopTeardownDeferred");
deferred.setAccessible(true);
deferred.set(plugin, new AtomicBoolean());
File report = new File(temporaryFolder.getRoot(), "failure.txt");
when(plugin.getDataFile(any(String[].class))).thenReturn(report);
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (PrintStream errors = new PrintStream(output, true, StandardCharsets.UTF_8);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Iris.instance = plugin;
IrisSettings.settings = new IrisSettings();
IrisSettings.settings.getGeneral().setDebug(true);
System.setErr(errors);
iris.when(() -> Iris.reportError(failure)).thenCallRealMethod();
Iris.reportError(failure);
assertTrue(Files.readString(report.toPath()).contains(failure.toString()));
return output.toString(StandardCharsets.UTF_8);
} finally {
System.setErr(previousError);
IrisSettings.settings = previousSettings;
Iris.instance = previousPlugin;
}
}
}
@@ -1,7 +1,17 @@
package art.arcane.iris;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.agent.Agent;
import art.arcane.iris.platform.bootstrap.SlimJar;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.plugin.IrisService;
import art.arcane.iris.platform.bukkit.plugin.VolmitPlugin;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.lifecycle.IrisRuntimeStatics;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.runtime.GenerationSessionManager;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
@@ -16,8 +26,8 @@ import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.WorldMaintenance;
import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.integration.ReloadAware;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.volmlib.integration.ReloadAware;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
@@ -27,10 +37,13 @@ import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
@@ -38,12 +51,16 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -152,13 +169,181 @@ public class IrisShutdownMaintenanceTest {
}
}
@Test
public void startupEnsuresAgentReadinessBeforeLifecycleProtectionWithoutWorldInjection() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Agent> agent = mockStatic(Agent.class)) {
when(fixture.binding.supportsIrisWorldGeneration()).thenReturn(true);
agent.when(Agent::install).thenReturn(true);
doAnswer(invocation -> {
agent.verify(Agent::install);
assertSame(fixture.plugin.getClass().getClassLoader(), invocation.getArgument(0));
return null;
}).when(fixture.binding).initializeLifecycle(fixture.plugin.getClass().getClassLoader());
invoke(fixture.plugin, "initializeShutdownProtection");
verify(fixture.binding).initializeLifecycle(fixture.plugin.getClass().getClassLoader());
verify(fixture.binding, never()).injectBukkit();
}
}
@Test
public void unavailableAgentStopsLifecycleInitialization() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Agent> agent = mockStatic(Agent.class)) {
when(fixture.binding.supportsIrisWorldGeneration()).thenReturn(true);
InvocationTargetException failure = assertThrows(InvocationTargetException.class,
() -> invoke(fixture.plugin, "initializeShutdownProtection"));
assertTrue(failure.getCause() instanceof IllegalStateException);
verify(fixture.binding, never()).initializeLifecycle(fixture.plugin.getClass().getClassLoader());
}
}
@Test
public void worldHookTeardownFailureReleasesProtectionAfterServicesAndWorkersStop() throws Exception {
try (Fixture fixture = new Fixture();
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class);
MockedStatic<SlimJar> providers = mockStatic(SlimJar.class)) {
IrisService service = mock(IrisService.class);
IllegalStateException failure = new IllegalStateException("world hook teardown failed");
fixture.nms.when(INMS::isBound).thenReturn(true);
doThrow(failure).when(fixture.binding).uninjectBukkit();
setField(fixture.plugin, "services", new KMap<Class<? extends IrisService>, IrisService>());
setField(fixture.plugin, "enabledServices", List.of(service));
doAnswer(invocation -> {
platforms.verify(IrisPlatforms::unbind);
bukkitPlatform.verify(BukkitPlatform::releaseHost);
assertTrue(MultiBurst.burst.isTerminated());
assertTrue(MultiBurst.hydrology.isTerminated());
assertTrue(MultiBurst.ioBurst.isTerminated());
return null;
}).when(fixture.binding).releasePluginClassLoaderClose();
fixture.quiesce();
try {
fixture.finish(5L);
fixture.finish(5L);
verify(service).onDisable();
iris.verify(() -> Iris.reportError("Failed to remove Iris world-generation hooks.", failure));
assertTrue(MultiBurst.burst.isTerminated());
assertTrue(MultiBurst.hydrology.isTerminated());
assertTrue(MultiBurst.ioBurst.isTerminated());
verify(fixture.binding).releasePluginClassLoaderClose();
platforms.verify(IrisPlatforms::unbind);
bukkitPlatform.verify(BukkitPlatform::releaseHost);
} finally {
MultiBurst.burst.reopen();
MultiBurst.hydrology.reopen();
MultiBurst.ioBurst.reopen();
}
}
}
@Test
public void failedServiceWithAnOwnedExecutorRetainsTheClassLoader() throws Exception {
ExecutorService ownedExecutor = Executors.newSingleThreadExecutor();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(1);
ownedExecutor.submit(() -> {
started.countDown();
finish.await();
return null;
});
try (Fixture fixture = new Fixture();
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class)) {
assertTrue(started.await(5, TimeUnit.SECONDS));
IrisService service = mock(IrisService.class);
IllegalStateException failure = new IllegalStateException("service teardown failed");
doThrow(failure).when(service).onDisable();
setField(fixture.plugin, "services", new KMap<Class<? extends IrisService>, IrisService>());
setField(fixture.plugin, "enabledServices", List.of(service));
fixture.quiesce();
fixture.finish(5L);
verify(service).onDisable();
iris.verify(() -> Iris.reportError("Failed to disable " + service.getClass().getSimpleName() + ".", failure));
assertFalse(ownedExecutor.isTerminated());
verify(fixture.binding, never()).releasePluginClassLoaderClose();
} finally {
finish.countDown();
ownedExecutor.shutdownNow();
assertTrue(ownedExecutor.awaitTermination(5, TimeUnit.SECONDS));
MultiBurst.burst.reopen();
MultiBurst.hydrology.reopen();
MultiBurst.ioBurst.reopen();
}
}
@Test
public void failedPostShutdownCallbackRetainsTheClassLoaderForSkippedCleanup() throws Exception {
ExecutorService ownedExecutor = Executors.newSingleThreadExecutor();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(1);
ownedExecutor.submit(() -> {
started.countDown();
finish.await();
return null;
});
try (Fixture fixture = new Fixture();
MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<IrisRuntimeStatics> statics = mockStatic(IrisRuntimeStatics.class);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
MockedStatic<BukkitPlatform> bukkitPlatform = mockStatic(BukkitPlatform.class)) {
assertTrue(started.await(5, TimeUnit.SECONDS));
IllegalStateException failure = new IllegalStateException("post-shutdown callback failed");
AtomicBoolean cleanupAttempted = new AtomicBoolean();
KList<Runnable> callbacks = new KList<>();
callbacks.add(() -> { throw failure; });
callbacks.add(() -> {
cleanupAttempted.set(true);
ownedExecutor.shutdown();
});
Field callbacksField = VolmitPlugin.class.getDeclaredField("postShutdown");
callbacksField.setAccessible(true);
callbacksField.set(fixture.plugin, callbacks);
Method runCallbacks = VolmitPlugin.class.getDeclaredMethod("runPostShutdown");
runCallbacks.setAccessible(true);
runCallbacks.invoke(doCallRealMethod().when(fixture.plugin));
fixture.quiesce();
fixture.finish(5L);
iris.verify(() -> Iris.reportError("Failed to run Iris post-shutdown cleanup.", failure));
assertFalse(cleanupAttempted.get());
assertFalse(ownedExecutor.isShutdown());
assertFalse(ownedExecutor.isTerminated());
verify(fixture.binding, never()).releasePluginClassLoaderClose();
} finally {
finish.countDown();
ownedExecutor.shutdownNow();
assertTrue(ownedExecutor.awaitTermination(5, TimeUnit.SECONDS));
MultiBurst.burst.reopen();
MultiBurst.hydrology.reopen();
MultiBurst.ioBurst.reopen();
}
}
@Test
public void incompleteDrainRetainsServicesWorkersAndClassLoader() throws Exception {
try (Fixture fixture = new Fixture(); MockedStatic<Iris> iris = mockStatic(Iris.class)) {
IrisService service = mock(IrisService.class);
setField(fixture.plugin, "services", new KMap<Class<? extends IrisService>, IrisService>());
setField(fixture.plugin, "enabledServices", List.of(service));
when(fixture.generator.closeAsync()).thenReturn(new CompletableFuture<>());
fixture.quiesce();
fixture.finish(0L);
verify(service, never()).onDisable();
verify(fixture.binding, never()).releasePluginClassLoaderClose();
verify(fixture.binding, never()).uninjectBukkit();
}
}
@Test
public void serverStopPreUnloadDefersTeardownWhileTheClassLoaderIsHeld() throws Exception {
try (Fixture fixture = new Fixture()) {
when(fixture.binding.deferPluginClassLoaderClose()).thenReturn(true);
setField(fixture.plugin, "postStopFinisherStarted", new AtomicBoolean(true));
try {
fixture.commitReloadDuringServerStop();
verify(fixture.binding).deferPluginClassLoaderClose();
verify(fixture.generator).quiesceForServerShutdown();
verify(fixture.generator, never()).closeAsync();
assertTrue(fixture.serverStopTeardownDeferred());
@@ -170,19 +355,43 @@ public class IrisShutdownMaintenanceTest {
@Test
public void serverStopPreUnloadDrainsInlineWhenTheClassLoaderCannotBeHeld() throws Exception {
try (Fixture fixture = new Fixture()) {
when(fixture.binding.deferPluginClassLoaderClose()).thenReturn(false);
try (Fixture fixture = new Fixture(); MockedStatic<Iris> iris = mockStatic(Iris.class)) {
IllegalStateException failure = new IllegalStateException("class loader hold failed");
doThrow(failure).when(fixture.binding).deferPluginClassLoaderClose();
try {
fixture.commitReloadDuringServerStop();
verify(fixture.generator).closeAsync();
verify(fixture.generator, never()).quiesceForServerShutdown();
assertFalse(fixture.serverStopTeardownDeferred());
iris.verify(() -> Iris.reportError("Failed to retain the Iris plugin class loader; draining shutdown inline.", failure));
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD", true);
}
}
}
@Test
public void serverStopPreUnloadDrainsInlineWithoutInitializedProtection() throws Exception {
try (Fixture fixture = new Fixture()) {
setField(fixture.plugin, "shutdownProtectionInitialized", false);
try {
fixture.commitReloadDuringServerStop();
verify(fixture.binding, never()).deferPluginClassLoaderClose();
verify(fixture.generator).closeAsync();
verify(fixture.generator, never()).quiesceForServerShutdown();
assertFalse(fixture.serverStopTeardownDeferred());
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD", true);
}
}
}
private static void invoke(Iris plugin, String name) throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod(name);
method.setAccessible(true);
method.invoke(plugin);
}
private static void setField(Iris plugin, String name, Object value) throws ReflectiveOperationException {
Field field = Iris.class.getDeclaredField(name);
field.setAccessible(true);
@@ -210,6 +419,12 @@ public class IrisShutdownMaintenanceTest {
private Fixture() throws ReflectiveOperationException {
IrisSettings.settings = new IrisSettings();
setField(plugin, "shutdownProtectionInitialized", true);
setField(plugin, "alreadyDrained", new AtomicBoolean());
setField(plugin, "servicesDisabled", new AtomicBoolean());
setField(plugin, "runtimeTeardownFailed", new AtomicBoolean());
setField(plugin, "sharedRuntimeClosed", new AtomicBoolean());
setField(plugin, "terminalCleanupCompleted", new AtomicBoolean());
setField(plugin, "serverStopTeardownDeferred", new AtomicBoolean());
setField(plugin, "postStopFinisherStarted", new AtomicBoolean());
setField(plugin, "deferredShutdownGenerators", new CopyOnWriteArrayList<PlatformChunkGenerator>());
@@ -251,6 +466,13 @@ public class IrisShutdownMaintenanceTest {
return ((AtomicBoolean) field.get(plugin)).get();
}
private void finish(long timeoutSeconds) throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("finishDeferredRuntimeTeardown", String.class, long.class);
method.setAccessible(true);
method.invoke(plugin, "test_shutdown", timeoutSeconds);
}
private void startFinisher() throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("startPostStopFinisher");
method.setAccessible(true);