mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-11-01 17:00:26 +00:00
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:
1 parent
f64a7c6753
commit
3dc958edcf
31 files changed
+1973
-217
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+227
-5
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user