Command Corrections.

This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-17 04:48:35 -04:00
1 parent 1e1eaa96aa
commit b18681a3e2
43 files changed
+1227 -251

No files matched your search

@@ -97,6 +97,59 @@ public class EngineShutdownDrainTest {
}
}
@Test
public void settledStructureBootstrapFailureDoesNotPreventEngineCleanup() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
setField(fixture.engine, "nativeStructureBootstrapBarrier", barrier);
CompletableFuture<Void> rings = new CompletableFuture<>();
barrier.start(() -> {
}, () -> rings);
rings.completeExceptionally(new IllegalStateException("Ring worker failed"));
doCallRealMethod().when(fixture.engine).drainNativeStructureBootstrap();
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
fixture.shutdown.close();
}
verify(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter();
verify(fixture.mantle).close();
assertTrue(fixture.engine.closed);
}
@Test
public void untrackedStructureBootstrapKeepsItsRuntimeAndRouterOpen() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
doThrow(new IllegalStateException("Bootstrap could not be tracked"))
.when(fixture.engine).drainNativeStructureBootstrap();
assertThrows(IllegalStateException.class, fixture.shutdown::close);
verify(fixture.engine, never()).closeAttachedGenerationHistoryRuntimeRouter();
verify(fixture.mantle, never()).close();
assertFalse(fixture.engine.closed);
}
@Test
public void structureBootstrapDrainsBeforeItsHistoryRouterCloses() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
AtomicBoolean ringsRunning = new AtomicBoolean(true);
doAnswer(invocation -> {
assertFalse(Thread.holdsLock(fixture.engine.lifecycleLock));
ringsRunning.set(false);
return null;
}).when(fixture.engine).drainNativeStructureBootstrap();
doAnswer(invocation -> {
assertFalse("Ring workers still need their generation-history router", ringsRunning.get());
return null;
}).when(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter();
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
fixture.shutdown.close();
}
assertTrue(fixture.engine.closed);
}
@Test
public void queuedWorldManagerRoutesAreCancelledBeforeRouterDrain() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
@@ -541,6 +594,11 @@ public class EngineShutdownDrainTest {
ShutdownFixture fixture = new ShutdownFixture();
IrisComplex replacement = mock(IrisComplex.class);
fixture.prepareComplexHotload(replacement);
doAnswer(invocation -> {
assertFalse("Ring workers need the lifecycle lock while hotload waits",
Thread.holdsLock(fixture.engine.lifecycleLock));
return null;
}).when(fixture.engine).awaitNativeStructureBootstrap("complex hotload");
new EngineHotloader(fixture.engine).hotloadComplex();
@@ -880,6 +880,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
setField(engine, "generationRuntimeRetirementListeners", new CopyOnWriteArraySet<IntConsumer>());
setField(engine, "shutdownSequence", new EngineShutdownSequence(engine));
setField(engine, "hotloader", new EngineHotloader(engine));
setField(engine, "nativeStructureBootstrapBarrier", new NativeStructureBootstrapBarrier());
setField(engine, "nativeStructureVolumeMemo", new NativeStructureVolumeMemo());
setField(engine, "closing", new AtomicBoolean(false));
engine.lifecycleState = IrisEngine.LifecycleState.RUNNING;
@@ -10,10 +10,72 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class NativeStructureBootstrapBarrierTest {
@Test
public void failedRingWorkersCanBeReleasedOnceTheirCompletionSettles() {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
CompletableFuture<Void> rings = new CompletableFuture<>();
IllegalStateException failure = new IllegalStateException("ring failure");
barrier.start(() -> {
}, () -> rings);
rings.completeExceptionally(failure);
assertSame(failure, barrier.awaitForClose());
assertFalse(barrier.isActive());
}
@Test
public void untrackedStarterFailureDoesNotAllowResourceRelease() {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
assertThrows(IllegalStateException.class, () -> barrier.start(() -> {
}, () -> {
throw new IllegalStateException("partial scheduling");
}));
assertThrows(IllegalStateException.class, barrier::awaitForClose);
assertTrue(barrier.isActive());
}
@Test
public void exposedCompletionCannotReleaseRunningRingWork() {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
CompletableFuture<Void> rings = new CompletableFuture<>();
CompletableFuture<Void> completion = barrier.start(() -> {
}, () -> rings);
completion.complete(null);
assertTrue(barrier.isActive());
rings.complete(null);
assertFalse(barrier.isActive());
}
@Test
public void waitingDoesNotHoldTheBarrierMonitor() throws Exception {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
CompletableFuture<Void> rings = new CompletableFuture<>();
barrier.start(() -> {
}, () -> rings);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<?> close = executor.submit(() -> barrier.await("close"));
assertThrows(TimeoutException.class, () -> close.get(50L, TimeUnit.MILLISECONDS));
Future<?> worker = executor.submit(() -> {
assertTrue(barrier.isActive());
rings.complete(null);
});
worker.get(1L, TimeUnit.SECONDS);
close.get(1L, TimeUnit.SECONDS);
} finally {
rings.complete(null);
executor.shutdownNow();
}
}
@Test
public void immediateCloseWaitsForExactRingCompletion() throws Exception {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
@@ -0,0 +1,74 @@
package art.arcane.iris.studio.jigsaw;
import art.arcane.volmlib.util.event.ProtectionProbe;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.PlayerInventory;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.UUID;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class JigsawStudioProtectionProbeTest {
@Test
public void previewDoesNotMarkStudioDirty() throws Exception {
JigsawStudioService service = new JigsawStudioService();
JigsawStudioDirtyTracker tracker = mock(JigsawStudioDirtyTracker.class);
JigsawStudioTileWatcher watcher = mock(JigsawStudioTileWatcher.class);
setField(service, "dirtyTracker", tracker);
setField(service, "tileWatcher", watcher);
PlayerInteractEvent event = probe();
service.protectionListener.onPlayerInteract(event);
verify(tracker, never()).markDirty(event.getClickedBlock());
verify(watcher, never()).finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
}
@Test
public void controlChestProbeRemainsDeniedWithoutOpeningItsMenu() throws Exception {
JigsawStudioService service = new JigsawStudioService();
JigsawStudioProtection protection = mock(JigsawStudioProtection.class);
JigsawStudioToolbelt toolbelt = mock(JigsawStudioToolbelt.class);
setField(service, "protection", protection);
setField(service, "toolbelt", toolbelt);
PlayerInteractEvent event = probe();
when(protection.isControlChest(event.getClickedBlock())).thenReturn(true);
try (MockedStatic<JigsawStudioProtection> authorization = mockStatic(JigsawStudioProtection.class)) {
authorization.when(() -> JigsawStudioProtection.authorizeOwner(event.getPlayer(), null)).thenReturn(true);
service.protectionListener.onControlChest(event);
assertTrue(event.isCancelled());
verify(toolbelt, never()).openControlMenu(any(Player.class));
}
}
private static PlayerInteractEvent probe() {
Player player = mock(Player.class);
World world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(player.getWorld()).thenReturn(world);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getInventory()).thenReturn(mock(PlayerInventory.class));
return ProtectionProbe.blockInteract(player, mock(Block.class), EquipmentSlot.HAND);
}
private static void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}
@@ -0,0 +1,92 @@
package art.arcane.iris.studio.jigsaw;
import java.lang.reflect.Field;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredListener;
import org.junit.Test;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class JigsawStudioToolInteractionTest {
@Test
public void airClickReachesTheToolDespiteBukkitsDefaultCancellation() throws Exception {
Fixture fixture = fixture();
PlayerInteractEvent event = event(fixture.player(), Action.RIGHT_CLICK_AIR, null);
assertTrue(event.isCancelled());
dispatch(fixture.listener(), event);
verify(fixture.toolbelt()).useTool(fixture.player(), fixture.payload(), null, false);
verify(fixture.watcher()).finalizeJigsawTileWatchesForPlayer(fixture.player().getUniqueId());
}
@Test
public void deniedBlockAndItemInteractionsDoNotUseTheTool() throws Exception {
Fixture fixture = fixture();
PlayerInteractEvent block = event(fixture.player(), Action.RIGHT_CLICK_BLOCK, mock(Block.class));
block.setUseInteractedBlock(Event.Result.DENY);
dispatch(fixture.listener(), block);
PlayerInteractEvent air = event(fixture.player(), Action.RIGHT_CLICK_AIR, null);
air.setUseItemInHand(Event.Result.DENY);
dispatch(fixture.listener(), air);
PlayerInteractEvent item = event(fixture.player(), Action.RIGHT_CLICK_BLOCK, mock(Block.class));
item.setUseItemInHand(Event.Result.DENY);
dispatch(fixture.listener(), item);
verifyNoInteractions(fixture.codec(), fixture.toolbelt(), fixture.watcher());
}
private static Fixture fixture() throws Exception {
JigsawStudioService service = new JigsawStudioService();
JigsawStudioToolbelt toolbelt = mock(JigsawStudioToolbelt.class);
JigsawStudioToolCodec codec = mock(JigsawStudioToolCodec.class);
JigsawStudioTileWatcher watcher = mock(JigsawStudioTileWatcher.class);
JigsawStudioToolPayload payload = mock(JigsawStudioToolPayload.class);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(codec.decode((ItemStack) null)).thenReturn(Optional.of(payload));
setField(service, "toolbelt", toolbelt);
setField(service, "tileWatcher", watcher);
setField(toolbelt, "toolCodec", codec);
return new Fixture(service.protectionListener, toolbelt, codec, watcher, payload, player);
}
private static PlayerInteractEvent event(Player player, Action action, Block block) {
return new PlayerInteractEvent(player, action, null, block, BlockFace.UP, EquipmentSlot.HAND);
}
private static void dispatch(JigsawStudioProtectionListener listener, PlayerInteractEvent event) throws Exception {
EventHandler annotation = JigsawStudioProtectionListener.class
.getMethod("onUseTool", PlayerInteractEvent.class).getAnnotation(EventHandler.class);
RegisteredListener registered = new RegisteredListener(listener,
(ignored, dispatched) -> listener.onUseTool((PlayerInteractEvent) dispatched),
EventPriority.LOWEST, mock(Plugin.class), annotation.ignoreCancelled());
registered.callEvent(event);
}
private static void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
private record Fixture(JigsawStudioProtectionListener listener, JigsawStudioToolbelt toolbelt,
JigsawStudioToolCodec codec, JigsawStudioTileWatcher watcher,
JigsawStudioToolPayload payload, Player player) {
}
}
@@ -0,0 +1,82 @@
package art.arcane.iris.studio.object;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.iris.platform.bukkit.nms.INMSBinding;
import art.arcane.iris.platform.generation.BukkitChunkGenerator;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.world.task.J;
import org.bukkit.World;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Supplier;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class StudioBootstrapActivationTest {
@Test(timeout = 5000L)
public void activationReturnsPendingFutureAndFinishesOnlyAfterNativeRings() throws Exception {
verifyActivation(false);
}
@Test(timeout = 5000L)
public void failedNativeRingsPreserveCauseAndDoNotEndBootstrap() throws Exception {
verifyActivation(true);
}
private void verifyActivation(boolean fail) throws Exception {
BukkitChunkGenerator provider = mock(BukkitChunkGenerator.class);
World world = mock(World.class);
INMSBinding nms = mock(INMSBinding.class);
CompletableFuture<Void> rings = new CompletableFuture<>();
when(nms.completeStudioStructureBootstrap(world)).thenReturn(rings);
try (MockedStatic<INMS> binding = mockStatic(INMS.class);
MockedStatic<J> scheduling = mockStatic(J.class)) {
binding.when(INMS::get).thenReturn(nms);
scheduling.when(() -> J.sfut(any(Supplier.class))).thenAnswer(invocation -> {
Supplier<?> supplier = invocation.getArgument(0);
return CompletableFuture.completedFuture(supplier.get());
});
scheduling.when(() -> J.sfut(any(Runnable.class))).thenAnswer(invocation -> {
Runnable runnable = invocation.getArgument(0);
runnable.run();
return CompletableFuture.completedFuture(null);
});
CompletableFuture<Void> activation = activate(world, provider);
assertFalse(activation.isDone());
verify(provider, never()).endStudioEntryBootstrap();
if (fail) {
IllegalStateException failure = new IllegalStateException("ring calculation failed");
rings.completeExceptionally(failure);
CompletionException reported = assertThrows(CompletionException.class, activation::join);
assertSame(failure, reported.getCause());
verify(provider, never()).endStudioEntryBootstrap();
} else {
rings.complete(null);
activation.join();
verify(provider).endStudioEntryBootstrap();
}
}
}
@SuppressWarnings("unchecked")
private CompletableFuture<Void> activate(World world, PlatformChunkGenerator provider) throws Exception {
Method method = StudioOpenCoordinator.class.getDeclaredMethod(
"endStudioEntryBootstrap", World.class, PlatformChunkGenerator.class);
method.setAccessible(true);
return (CompletableFuture<Void>) method.invoke(StudioOpenCoordinator.get(), world, provider);
}
}
@@ -0,0 +1,122 @@
package art.arcane.iris.studio.object;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator;
import org.bukkit.World;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class StudioCloseRecoveryTest {
@Test
public void failedGeneratorCloseKeepsMutationReservationWithoutRestartingServer() throws Exception {
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
IllegalStateException failure = new IllegalStateException("generator cleanup failed");
when(provider.closeAsync()).thenReturn(CompletableFuture.failedFuture(failure));
LifecycleOperationCoordinator coordinator = mock(LifecycleOperationCoordinator.class);
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
when(coordinator.acquire(any(), any(), anyString())).thenReturn(lease);
try (MockedStatic<LifecycleOperationCoordinator> lifecycle = mockStatic(LifecycleOperationCoordinator.class);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator);
StudioOpenCoordinator.StudioCloseResult result = close(provider, null).join();
assertSame(failure, result.failureCause());
configurator.verifyNoInteractions();
verify(lease, never()).close();
}
}
@Test
public void pendingGeneratorRetainsProjectAndMutationReservationUntilCleanupFinishes() throws Exception {
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
CompletableFuture<Void> generatorClose = new CompletableFuture<>();
when(provider.closeAsync()).thenReturn(generatorClose);
IrisProject project = mock(IrisProject.class);
LifecycleOperationCoordinator coordinator = mock(LifecycleOperationCoordinator.class);
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
when(coordinator.acquire(any(), any(), anyString())).thenReturn(lease);
try (MockedStatic<LifecycleOperationCoordinator> lifecycle = mockStatic(LifecycleOperationCoordinator.class);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator);
CompletableFuture<StudioOpenCoordinator.StudioCloseResult> close = close(provider, project);
assertFalse(close.isDone());
verifyNoInteractions(project);
verify(lease, never()).close();
generatorClose.complete(null);
close.join();
verify(project).setActiveProvider(null);
verify(project).setActiveOpenKind(null);
verify(lease).close();
configurator.verifyNoInteractions();
}
}
@Test
public void cancellingCloseResultDoesNotSkipCleanupOrLeaseRelease() throws Exception {
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
CompletableFuture<Void> generatorClose = new CompletableFuture<>();
when(provider.closeAsync()).thenReturn(generatorClose);
IrisProject project = mock(IrisProject.class);
LifecycleOperationCoordinator coordinator = mock(LifecycleOperationCoordinator.class);
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
when(coordinator.acquire(any(), any(), anyString())).thenReturn(lease);
try (MockedStatic<LifecycleOperationCoordinator> lifecycle = mockStatic(LifecycleOperationCoordinator.class);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator);
CompletableFuture<StudioOpenCoordinator.StudioCloseResult> result = close(provider, project);
assertTrue(result.cancel(false));
assertFalse(generatorClose.isDone());
verifyNoInteractions(project);
verify(lease, never()).close();
generatorClose.complete(null);
assertTrue(result.isCancelled());
verify(project).setActiveProvider(null);
verify(project).setActiveOpenKind(null);
verify(lease).close();
configurator.verifyNoInteractions();
}
}
@SuppressWarnings("unchecked")
private CompletableFuture<StudioOpenCoordinator.StudioCloseResult> close(
PlatformChunkGenerator provider,
IrisProject project
) throws Exception {
Method method = StudioOpenCoordinator.class.getDeclaredMethod("closeWorldCoordinated",
PlatformChunkGenerator.class, String.class, World.class, boolean.class, IrisProject.class);
method.setAccessible(true);
return (CompletableFuture<StudioOpenCoordinator.StudioCloseResult>) method.invoke(
StudioOpenCoordinator.get(), provider, null, null, false, project);
}
}
@@ -8,9 +8,11 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import art.arcane.iris.spi.IrisLogging;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.mockStatic;
@@ -77,47 +79,32 @@ public class StudioOpenCoordinatorCloseSequenceTest {
}
@Test
public void terminalTimeoutPreventsLateUnloadFromClosingOrDeleting() {
public void slowCloseKeepsWaitingAndFinishesRemainingPhasesWithoutRestart() {
ArrayList<String> phases = new ArrayList<>();
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
CompletableFuture<Void> unload = new CompletableFuture<>();
AtomicReference<Runnable> warning = new AtomicReference<>();
try (MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose(
() -> phase(phases, "evacuate"),
() -> {
phases.add("unload");
return unload;
},
() -> phase(phases, "close-generator"),
() -> phase(phases, "delete-folders"));
StudioOpenCoordinator.warnOnSlowClose(close, "studio-world", warning::set);
CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose(
() -> phase(phases, "evacuate"),
() -> {
phases.add("unload");
return unload;
},
() -> phase(phases, "close-generator"),
() -> phase(phases, "delete-folders"),
terminalTimeout::get);
warning.get().run();
assertFalse(close.isDone());
assertFalse(unload.isDone());
assertEquals(List.of("evacuate", "unload"), phases);
configurator.verifyNoInteractions();
terminalTimeout.set(true);
unload.complete(null);
try {
unload.complete(null);
close.join();
fail("Expected terminal timeout");
} catch (CompletionException exception) {
assertEquals("Studio close stopped after its terminal timeout.", exception.getCause().getMessage());
}
assertEquals(List.of("evacuate", "unload"), phases);
}
@Test
public void closeFailureAfterUnloadBeginsRequestsTerminalRestart() {
try (MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class)) {
StudioOpenCoordinator.requestRestartAfterPartialClose("studio-world", true);
configurator.verify(() -> ServerConfigurator.restart(
"Studio close failed after world unload began for \"studio-world\"."));
}
}
@Test
public void closeFailureBeforeUnloadDoesNotRequestRestart() {
try (MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class)) {
StudioOpenCoordinator.requestRestartAfterPartialClose("studio-world", false);
assertEquals(List.of("evacuate", "unload", "close-generator", "delete-folders"), phases);
configurator.verifyNoInteractions();
}
}
@@ -0,0 +1,89 @@
package art.arcane.iris.studio.view;
import art.arcane.iris.configuration.IrisSettings;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.awt.AWTEvent;
import java.awt.EventQueue;
import java.awt.Toolkit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertNull;
public class DesktopGuiLaunchTest {
private IrisSettings previousSettings;
private boolean previousSuppressed;
private RecordingEventQueue events;
@Before
public void suppressDesktop() throws Exception {
previousSettings = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
previousSuppressed = GuiHost.isDesktopSuppressed();
GuiHost.suppressDesktop(true);
events = new RecordingEventQueue();
Toolkit.getDefaultToolkit().getSystemEventQueue().push(events);
EventQueue.invokeAndWait(() -> {
});
}
@After
public void restoreDesktop() throws Exception {
EventQueue.invokeAndWait(events::remove);
GuiHost.suppressDesktop(previousSuppressed);
IrisSettings.settings = previousSettings;
}
@Test
public void mapLaunchDoesNotCreateAWindowWithoutDesktop() throws Exception {
VisionGUI.launch(null, null);
assertNoDesktopFailure();
}
@Test
public void noiseLaunchDoesNotCreateAWindowWithoutDesktop() throws Exception {
NoiseExplorerGUI.launch();
assertNoDesktopFailure();
}
@Test
public void generatorLaunchDoesNotCreateAWindowWithoutDesktop() throws Exception {
NoiseExplorerGUI.launchGeneratorKey("test", null, 12345L);
assertNoDesktopFailure();
}
@Test
public void imageMapLaunchDoesNotReadEngineWithoutDesktop() throws Exception {
ImageMapStudioGUI.launch(null);
assertNoDesktopFailure();
}
private void assertNoDesktopFailure() throws Exception {
EventQueue.invokeAndWait(() -> {
});
assertNull("Desktop launch raised an exception on the AWT event thread", events.failure.get());
}
private static final class RecordingEventQueue extends EventQueue {
private final AtomicReference<Throwable> failure = new AtomicReference<>();
@Override
protected void dispatchEvent(AWTEvent event) {
try {
super.dispatchEvent(event);
} catch (Throwable error) {
failure.compareAndSet(null, error);
}
}
private void remove() {
pop();
}
}
}
@@ -1,5 +1,7 @@
package art.arcane.iris.world.lifecycle;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
@@ -10,9 +12,13 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
@@ -22,6 +28,7 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
@@ -237,6 +244,96 @@ public class WorldLifecycleUnloadAsyncTest {
assertEquals("remembered", service.backendNameForWorld(worldKey));
}
@Test
public void settledUnloadRetainsTheBoundaryUntilBackendCompletionWithoutATerminalTimer() {
ControlledBackend remembered = new ControlledBackend("remembered");
WorldLifecycleService service = service(remembered, new ControlledBackend("fallback"));
NamespacedKey worldKey = new NamespacedKey("iris", "settled_boundary");
World world = world(worldKey);
service.rememberBackend(worldKey, remembered.backendName());
ArrayList<Runnable> timeouts = new ArrayList<>();
Executor captureTimeout = timeouts::add;
try (MockedStatic<CompletableFuture> futures = mockStatic(CompletableFuture.class, CALLS_REAL_METHODS)) {
futures.when(() -> CompletableFuture.delayedExecutor(120L, TimeUnit.SECONDS))
.thenReturn(captureTimeout);
CompletableFuture<Boolean> result = service.unloadSettledAsync(world, false);
CompletionStage<Boolean> boundary = WorldUnloadBoundaryRegistry.claim(worldKey.toString());
assertNotNull(boundary);
assertFalse(boundary.toCompletableFuture().isDone());
assertFalse(result.isDone());
assertTrue(timeouts.isEmpty());
assertEquals("remembered", service.backendNameForWorld(worldKey));
remembered.complete(true);
assertTrue(result.join());
assertTrue(boundary.toCompletableFuture().join());
assertEquals("fallback", service.backendNameForWorld(worldKey));
}
}
@Test
public void cancellingSettledUnloadResultPreservesBackendCompletionBookkeeping() {
ControlledBackend remembered = new ControlledBackend("remembered");
WorldLifecycleService service = service(remembered, new ControlledBackend("fallback"));
NamespacedKey worldKey = new NamespacedKey("iris", "cancelled_settlement");
World world = world(worldKey);
service.rememberBackend(worldKey, remembered.backendName());
CompletableFuture<Boolean> result = service.unloadSettledAsync(world, false);
CompletionStage<Boolean> boundary = WorldUnloadBoundaryRegistry.claim(worldKey.toString());
assertNotNull(boundary);
assertTrue(result.cancel(false));
assertFalse(boundary.toCompletableFuture().isDone());
assertEquals("remembered", service.backendNameForWorld(worldKey));
remembered.complete(true);
assertTrue(result.isCancelled());
assertTrue(boundary.toCompletableFuture().join());
assertEquals("fallback", service.backendNameForWorld(worldKey));
}
@Test
public void guardedUnloadKeepsItsTimeoutAndTracksLateBackendSuccess() {
ControlledBackend remembered = new ControlledBackend("remembered");
WorldLifecycleService service = service(remembered, new ControlledBackend("fallback"));
NamespacedKey worldKey = new NamespacedKey("iris", "late_settlement");
World world = world(worldKey);
service.rememberBackend(worldKey, remembered.backendName());
ArrayList<Runnable> timeouts = new ArrayList<>();
Executor captureTimeout = timeouts::add;
try (MockedStatic<CompletableFuture> futures = mockStatic(CompletableFuture.class, CALLS_REAL_METHODS);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class)) {
futures.when(() -> CompletableFuture.delayedExecutor(120L, TimeUnit.SECONDS))
.thenReturn(captureTimeout);
CompletableFuture<Boolean> result = service.unloadAsync(world, false);
CompletionStage<Boolean> boundary = WorldUnloadBoundaryRegistry.claim(worldKey.toString());
assertEquals(1, timeouts.size());
timeouts.getFirst().run();
CompletionException failure = assertThrows(CompletionException.class, result::join);
assertTrue(failure.getCause() instanceof TimeoutException);
configurator.verify(() -> ServerConfigurator.restart("World unload timed out for \"late_settlement\"."));
assertFalse(boundary.toCompletableFuture().isDone());
assertEquals("remembered", service.backendNameForWorld(worldKey));
remembered.complete(true);
assertTrue(boundary.toCompletableFuture().join());
assertTrue(result.isCompletedExceptionally());
assertEquals("fallback", service.backendNameForWorld(worldKey));
}
}
private static WorldLifecycleService service(
ControlledBackend remembered,
ControlledBackend fallback