Fixed shutdown teardown and shutdown staging wait

- Server stop defers teardown only while the plugin class loader close is held
- Shutdown with no Iris world loaded tears down during plugin disable
- Pre-unload hook drains generators inline when the class loader close cannot be held
- INMSBinding reports whether the class loader close is held
- Shutdown tests cover the held and unheld pre-unload paths
- Shutdown waits for an in-progress world replacement to finish staging
- Replacements started after shutdown begins are refused
- Admission tests cover the shutdown wait and the refusal
This commit is contained in:
SwiftSwamp committed 2026-10-26 09:26:30 -06:00
1 parent 494cdb1a5c
commit 2690e0878b
6 files changed
+169 -8

No files matched your search

@@ -16,6 +16,7 @@ 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 org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
@@ -37,9 +38,11 @@ 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.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.mockStatic;
import static org.mockito.Mockito.never;
@@ -109,8 +112,6 @@ public class IrisShutdownMaintenanceTest {
}
verify(fixture.generator).quiesceForServerShutdown();
verify(fixture.generator, never()).closeAsync();
fixture.nms.verify(INMS::get);
verify(fixture.binding).deferPluginClassLoaderClose();
verify(fixture.binding, never()).releasePluginClassLoaderClose();
}
assertEquals(0, sessions.activeLeases());
@@ -151,6 +152,37 @@ public class IrisShutdownMaintenanceTest {
}
}
@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.generator).quiesceForServerShutdown();
verify(fixture.generator, never()).closeAsync();
assertTrue(fixture.serverStopTeardownDeferred());
} finally {
WorldMaintenance.endWorldMaintenance(fixture.identity, "pre-unload:HOT_UNLOAD");
}
}
}
@Test
public void serverStopPreUnloadDrainsInlineWhenTheClassLoaderCannotBeHeld() throws Exception {
try (Fixture fixture = new Fixture()) {
when(fixture.binding.deferPluginClassLoaderClose()).thenReturn(false);
try {
fixture.commitReloadDuringServerStop();
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 setField(Iris plugin, String name, Object value) throws ReflectiveOperationException {
Field field = Iris.class.getDeclaredField(name);
field.setAccessible(true);
@@ -206,6 +238,19 @@ public class IrisShutdownMaintenanceTest {
method.invoke(plugin, "manual_disable", 5L);
}
private void commitReloadDuringServerStop() throws ReflectiveOperationException {
setField(plugin, "alreadyDrained", new AtomicBoolean());
toolbelt.when(IrisToolbelt::isServerStopping).thenReturn(true);
doCallRealMethod().when(plugin).commitReload(any());
plugin.commitReload(ReloadAware.PreUnloadReason.HOT_UNLOAD);
}
private boolean serverStopTeardownDeferred() throws ReflectiveOperationException {
Field field = Iris.class.getDeclaredField("serverStopTeardownDeferred");
field.setAccessible(true);
return ((AtomicBoolean) field.get(plugin)).get();
}
private void startFinisher() throws ReflectiveOperationException {
Method method = Iris.class.getDeclaredMethod("startPostStopFinisher");
method.setAccessible(true);
@@ -11,12 +11,16 @@ import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import org.bukkit.NamespacedKey;
import org.junit.Test;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
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.assertThrows;
import static org.mockito.Mockito.mock;
@@ -42,4 +46,55 @@ public class PendingWorldReplacementAdmissionTest {
executor.awaitTermination(5L, TimeUnit.SECONDS);
}
}
@Test
public void shutdownRejectsReplacementsThatStartAfterIt() {
PendingWorldReplacementManager manager = new PendingWorldReplacementManager(mock(Iris.class));
manager.awaitStagingForShutdown();
IOException rejected = assertThrows(IOException.class, () -> manager.stageReplacement(
mock(VolmitSender.class), NamespacedKey.minecraft("overworld"), mock(IrisDimension.class), null));
assertTrue(rejected.getMessage().contains("shutting down"));
assertTrue(LifecycleOperationCoordinator.get().isIdle());
}
@Test
public void shutdownWaitsForTheReplacementBeingStaged() throws Exception {
PendingWorldReplacementManager manager = new PendingWorldReplacementManager(mock(Iris.class));
AtomicReference<Throwable> stagingFailure = new AtomicReference<>();
Thread staging = new Thread(() -> {
try {
manager.stageReplacement(mock(VolmitSender.class), NamespacedKey.minecraft("overworld"),
mock(IrisDimension.class), null);
} catch (Throwable failure) {
stagingFailure.set(failure);
}
});
Thread shutdown = new Thread(manager::awaitStagingForShutdown);
synchronized (manager) {
staging.start();
awaitState(staging, Thread.State.BLOCKED);
shutdown.start();
awaitState(shutdown, Thread.State.WAITING);
assertTrue(staging.isAlive());
}
shutdown.join(TimeUnit.SECONDS.toMillis(10L));
staging.join(TimeUnit.SECONDS.toMillis(10L));
assertFalse(shutdown.isAlive());
assertFalse(staging.isAlive());
assertTrue(stagingFailure.get() instanceof Exception);
assertTrue(LifecycleOperationCoordinator.get().isIdle());
}
private static void awaitState(Thread thread, Thread.State state) throws InterruptedException {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L);
while (thread.getState() != state) {
if (System.nanoTime() > deadline) {
throw new AssertionError(thread.getName() + " never reached " + state + "; it is " + thread.getState());
}
Thread.sleep(5L);
}
}
}