From b18681a3e2c07b31f421f00a482dff2e1fca7ece Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 17 Sep 2026 04:48:35 -0400 Subject: [PATCH] Command Corrections. --- .../art/arcane/iris/command/CommandFind.java | 79 +++++-- .../arcane/iris/command/CommandStudio.java | 9 +- .../command/CommandFindHydrologyTest.java | 178 ++++++++++++++ .../command/CommandStudioDesktopTest.java | 83 +++++++ .../generation/runtime/EngineHotloader.java | 6 +- .../runtime/EngineShutdownSequence.java | 6 +- .../iris/generation/runtime/IrisEngine.java | 15 ++ .../NativeStructureBootstrapBarrier.java | 71 ++++-- .../BukkitCommandMessagesExtended.java | 5 + .../JigsawStudioProtectionListener.java | 15 +- .../object/ObjectStudioSaveService.java | 4 + .../studio/object/StudioOpenCoordinator.java | 217 +++++------------- .../iris/studio/view/ImageMapStudioGUI.java | 9 +- .../iris/studio/view/NoiseExplorerGUI.java | 12 + .../arcane/iris/studio/view/VisionGUI.java | 6 + .../lifecycle/WorldLifecycleService.java | 10 +- core/src/main/resources/languages/de_DE.toml | 1 + core/src/main/resources/languages/es_ES.toml | 1 + core/src/main/resources/languages/fi_FI.toml | 1 + core/src/main/resources/languages/fr_FR.toml | 1 + core/src/main/resources/languages/he_IL.toml | 1 + core/src/main/resources/languages/it_IT.toml | 1 + core/src/main/resources/languages/ja-JP.toml | 1 + core/src/main/resources/languages/ko_KR.toml | 1 + core/src/main/resources/languages/lt_LT.toml | 1 + core/src/main/resources/languages/nl_NL.toml | 1 + core/src/main/resources/languages/pl_PL.toml | 1 + core/src/main/resources/languages/pt_PT.toml | 1 + core/src/main/resources/languages/ru_RU.toml | 1 + core/src/main/resources/languages/tr_TR.toml | 1 + core/src/main/resources/languages/vi_VI.toml | 1 + core/src/main/resources/languages/zh_CN.toml | 1 + core/src/main/resources/languages/zh_TW.toml | 1 + .../runtime/EngineShutdownDrainTest.java | 58 +++++ .../IrisEngineGenerationRuntimeScopeTest.java | 1 + .../NativeStructureBootstrapBarrierTest.java | 62 +++++ .../JigsawStudioProtectionProbeTest.java | 74 ++++++ .../JigsawStudioToolInteractionTest.java | 92 ++++++++ .../object/StudioBootstrapActivationTest.java | 82 +++++++ .../object/StudioCloseRecoveryTest.java | 122 ++++++++++ ...tudioOpenCoordinatorCloseSequenceTest.java | 59 ++--- .../studio/view/DesktopGuiLaunchTest.java | 89 +++++++ .../WorldLifecycleUnloadAsyncTest.java | 97 ++++++++ 43 files changed, 1227 insertions(+), 251 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandFindHydrologyTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandStudioDesktopTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionProbeTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolInteractionTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/object/StudioBootstrapActivationTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/object/StudioCloseRecoveryTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/view/DesktopGuiLaunchTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java index 75777bdd7..bdd0fdf05 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java @@ -122,7 +122,7 @@ public class CommandFind implements DirectorExecutor { EngineBukkitOps.gotoPOI(e, type, player(), teleport); } - @Director(description = "Find an accepted hydrology feature") + @Director(description = "Find an accepted hydrology feature", sync = true) public void river( @Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class) String type, @@ -147,6 +147,7 @@ public class CommandFind implements DirectorExecutor { return; } Location origin = target.getLocation(); + World originWorld = origin.getWorld(); int requestedDistance = runtime == null ? 8192 : Math.min(8192, runtime.settings().routing().tileSize() * 15); @@ -179,9 +180,9 @@ public class CommandFind implements DirectorExecutor { return; } Location destination = new Location( - target.getWorld(), feature.x(), feature.y(), feature.z()); + originWorld, feature.x(), feature.y(), feature.z()); prepareStructureTeleport( - target, target.getWorld(), commandSender, label, destination, true); + target, originWorld, commandSender, label, destination, true); } catch (Throwable error) { sendStructureMessage(target, commandSender, C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName()); @@ -737,22 +738,41 @@ public class CommandFind implements DirectorExecutor { private void prepareStructureTeleport(Player target, World world, VolmitSender commandSender, String structure, Location at, boolean useLocatedY) { - int chunkX = at.getBlockX() >> 4; - int chunkZ = at.getBlockZ() >> 4; - BukkitPlatform.chunkAtAsync(world, chunkX, chunkZ, true).whenComplete((chunk, error) -> { - if (error != null) { - sendStructureMessage(target, commandSender, C.RED + "Could not load the destination for " + structure + "."); - Iris.reportError("Could not load structure destination '" + structure + "'.", error); + J.runEntity(target, () -> { + if (!target.isOnline()) { return; } - boolean scheduled = J.runRegion(world, chunkX, chunkZ, - () -> teleportToStructure(target, world, commandSender, structure, at, useLocatedY)); - if (!scheduled) { - sendStructureMessage(target, commandSender, C.RED + "Could not schedule the destination lookup for " + structure + "."); + if (!world.equals(target.getWorld())) { + commandSender.sendMessage(C.YELLOW + "Cancelled teleport to " + structure + " because your world changed."); + return; } + loadStructureDestination(target, world, commandSender, structure, at, useLocatedY); }); } + private void loadStructureDestination(Player target, World world, VolmitSender commandSender, String structure, + Location at, boolean useLocatedY) { + int chunkX = at.getBlockX() >> 4; + int chunkZ = at.getBlockZ() >> 4; + try { + BukkitPlatform.chunkAtAsync(world, chunkX, chunkZ, true).whenComplete((chunk, error) -> { + if (error != null) { + sendStructureMessage(target, commandSender, C.RED + "Could not load the destination for " + structure + "."); + Iris.reportError("Could not load structure destination '" + structure + "'.", error); + return; + } + boolean scheduled = J.runRegion(world, chunkX, chunkZ, + () -> teleportToStructure(target, world, commandSender, structure, at, useLocatedY)); + if (!scheduled) { + sendStructureMessage(target, commandSender, C.RED + "Could not schedule the destination lookup for " + structure + "."); + } + }); + } catch (Throwable error) { + sendStructureMessage(target, commandSender, C.RED + "Could not load the destination for " + structure + "."); + Iris.reportError("Could not load structure destination '" + structure + "'.", error); + } + } + private void teleportToStructure(Player target, World world, VolmitSender commandSender, String structure, Location at, boolean useLocatedY) { try { @@ -760,16 +780,41 @@ public class CommandFind implements DirectorExecutor { ? Math.max(world.getMinHeight() + 1, Math.min(world.getMaxHeight() - 1, at.getBlockY() + 2)) : world.getHighestBlockYAt(at.getBlockX(), at.getBlockZ()) + 2; Location destination = new Location(world, at.getBlockX() + 0.5, y, at.getBlockZ() + 0.5); - J.runEntity(target, () -> { - BukkitPlatform.teleportAsync(target, destination); - commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_TELEPORTED, MessageArgument.untrusted("structure", structure), MessageArgument.untrusted("value", at.getBlockX()), MessageArgument.untrusted("y", y), MessageArgument.untrusted("value2", at.getBlockZ()))); - }); + J.runEntity(target, () -> completeStructureTeleport(target, world, commandSender, structure, destination)); } catch (Throwable t) { sendStructureMessage(target, commandSender, C.RED + "Could not prepare the destination for " + structure + "."); Iris.reportError("Could not prepare structure destination '" + structure + "'.", t); } } + private void completeStructureTeleport(Player target, World world, VolmitSender commandSender, String structure, + Location destination) { + if (!target.isOnline()) { + return; + } + if (!world.equals(target.getWorld())) { + commandSender.sendMessage(C.YELLOW + "Cancelled teleport to " + structure + " because your world changed."); + return; + } + try { + BukkitPlatform.teleportAsync(target, destination).whenComplete((teleported, error) -> { + if (error != null) { + Iris.reportError("Could not teleport to '" + structure + "'.", error); + } + sendStructureMessage(target, commandSender, error == null && Boolean.TRUE.equals(teleported) + ? IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_TELEPORTED, + MessageArgument.untrusted("structure", structure), + MessageArgument.untrusted("value", destination.getBlockX()), + MessageArgument.untrusted("y", destination.getBlockY()), + MessageArgument.untrusted("value2", destination.getBlockZ())) + : C.RED + "Could not teleport to " + structure + "."); + }); + } catch (Throwable error) { + commandSender.sendMessage(C.RED + "Could not teleport to " + structure + "."); + Iris.reportError("Could not teleport to '" + structure + "'.", error); + } + } + private void sendStructureMessage(Player target, VolmitSender commandSender, String message) { J.runEntity(target, () -> commandSender.sendMessage(message)); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java index c21870474..493df97fb 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandStudio.java @@ -21,6 +21,7 @@ package art.arcane.iris.command; import art.arcane.iris.Iris; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.studio.view.GuiHost; import art.arcane.iris.studio.view.ImageMapStudioGUI; import art.arcane.iris.studio.view.NoiseExplorerGUI; import art.arcane.iris.studio.view.VisionGUI; @@ -274,10 +275,6 @@ public class CommandStudio implements DirectorExecutor { if (noGUI()) { return; } - if (!ImageMapStudioGUI.isAvailable()) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS)); - return; - } if (!IrisToolbelt.isIrisWorld(world)) { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD)); return; @@ -883,6 +880,10 @@ public class CommandStudio implements DirectorExecutor { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS)); return true; } + if (!GuiHost.isAvailable()) { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_DESKTOP_UNAVAILABLE)); + return true; + } return false; } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandFindHydrologyTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandFindHydrologyTest.java new file mode 100644 index 000000000..8e5d2fce3 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandFindHydrologyTest.java @@ -0,0 +1,178 @@ +package art.arcane.iris.command; + +import art.arcane.iris.Iris; +import art.arcane.iris.generation.hydrology.HydrologyFeatureType; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.localization.IrisLanguage; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.platform.bukkit.plugin.VolmitSender; +import art.arcane.iris.world.history.GenerationSemanticQueries; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.util.director.annotations.Director; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.Optional; +import java.util.OptionalLong; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +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; +import static org.mockito.Mockito.CALLS_REAL_METHODS; + +public class CommandFindHydrologyTest { + @Test + public void riverCapturesPlayerStateOnTheOwningThread() throws Exception { + assertTrue(CommandFind.class.getMethod("river", String.class, boolean.class) + .getAnnotation(Director.class).sync()); + } + + @Test + public void worldChangeDuringSearchDoesNotLoadOrTeleportIntoTheNewWorld() { + try (Fixture fixture = new Fixture()) { + fixture.command.river("surface", true); + when(fixture.player.getWorld()).thenReturn(mock(World.class)); + fixture.search.get().run(); + + fixture.platform.verify(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true)), never()); + fixture.platform.verify(() -> BukkitPlatform.teleportAsync(any(Player.class), any(Location.class)), never()); + verify(fixture.sender).sendMessage(contains("world changed")); + } + } + + @Test + public void disconnectDuringSearchDoesNotLoadTheDestination() { + try (Fixture fixture = new Fixture()) { + fixture.command.river("surface", true); + when(fixture.player.isOnline()).thenReturn(false); + fixture.search.get().run(); + + fixture.platform.verify(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true)), never()); + } + } + + @Test + public void worldChangeWhileLoadingTheDestinationCancelsTeleport() { + try (Fixture fixture = new Fixture()) { + fixture.command.river("surface", true); + fixture.search.get().run(); + when(fixture.player.getWorld()).thenReturn(mock(World.class)); + fixture.chunk.complete(mock(Chunk.class)); + + fixture.platform.verify(() -> BukkitPlatform.teleportAsync(any(Player.class), any(Location.class)), never()); + verify(fixture.sender).sendMessage(contains("world changed")); + } + } + + @Test + public void successIsReportedOnlyAfterTeleportCompletes() { + try (Fixture fixture = new Fixture()) { + fixture.command.river("surface", true); + fixture.search.get().run(); + fixture.chunk.complete(mock(Chunk.class)); + + verify(fixture.sender, never()).sendMessage("teleported"); + fixture.teleport.complete(true); + verify(fixture.sender).sendMessage("teleported"); + } + } + + @Test + public void rejectedTeleportReportsFailureWithoutSuccess() { + try (Fixture fixture = new Fixture()) { + fixture.command.river("surface", true); + fixture.search.get().run(); + fixture.chunk.complete(mock(Chunk.class)); + fixture.teleport.complete(false); + + verify(fixture.sender, never()).sendMessage("teleported"); + verify(fixture.sender).sendMessage(contains("Could not teleport")); + } + } + + @Test + public void failedTeleportReportsTheFullCauseWithoutSuccess() { + try (Fixture fixture = new Fixture()) { + IllegalStateException failure = new IllegalStateException("destination unloaded"); + fixture.command.river("surface", true); + fixture.search.get().run(); + fixture.chunk.complete(mock(Chunk.class)); + fixture.teleport.completeExceptionally(failure); + + verify(fixture.sender, never()).sendMessage("teleported"); + verify(fixture.sender).sendMessage(contains("Could not teleport")); + fixture.logging.verify(() -> Iris.reportError(any(String.class), eq(failure))); + } + } + + private static final class Fixture implements AutoCloseable { + private final CommandFind command = mock(CommandFind.class, CALLS_REAL_METHODS); + private final Engine engine = mock(Engine.class); + private final IrisComplex complex = mock(IrisComplex.class); + private final Player player = mock(Player.class); + private final World world = mock(World.class); + private final VolmitSender sender = mock(VolmitSender.class); + private final AtomicReference search = new AtomicReference<>(); + private final CompletableFuture chunk = new CompletableFuture<>(); + private final CompletableFuture teleport = new CompletableFuture<>(); + private final MockedStatic scheduler = mockStatic(J.class); + private final MockedStatic platform = mockStatic(BukkitPlatform.class); + private final MockedStatic queries = mockStatic(GenerationSemanticQueries.class); + private final MockedStatic language = mockStatic(IrisLanguage.class, invocation -> "teleported"); + private final MockedStatic logging = mockStatic(Iris.class); + + private Fixture() { + doReturn(engine).when(command).engine(); + doReturn(player).when(command).player(); + doReturn(sender).when(command).sender(); + when(engine.getComplex()).thenReturn(complex); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, 0, 70, 0)); + when(player.isOnline()).thenReturn(true); + when(world.getMinHeight()).thenReturn(-64); + when(world.getMaxHeight()).thenReturn(320); + scheduler.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> { + search.set(invocation.getArgument(0)); + return null; + }); + scheduler.when(() -> J.runEntity(eq(player), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1).run(); + return true; + }); + scheduler.when(() -> J.runRegion(any(World.class), anyInt(), anyInt(), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(3).run(); + return true; + }); + platform.when(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true))).thenReturn(chunk); + platform.when(() -> BukkitPlatform.teleportAsync(eq(player), any(Location.class))).thenReturn(teleport); + queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(engine), any(), eq(0), eq(0), anyInt(), any())) + .thenReturn(Optional.of(new GenerationSemanticQueries.RiverResult( + GenerationSemanticQueries.RiverSource.ACTIVE_PREDICTION, + HydrologyFeatureType.RIFFLE, Optional.empty(), 1L, 32, 63, 32, OptionalLong.empty()))); + } + + @Override + public void close() { + logging.close(); + language.close(); + queries.close(); + platform.close(); + scheduler.close(); + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandStudioDesktopTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandStudioDesktopTest.java new file mode 100644 index 000000000..3744b6042 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandStudioDesktopTest.java @@ -0,0 +1,83 @@ +package art.arcane.iris.command; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.platform.bukkit.plugin.VolmitSender; +import art.arcane.iris.studio.view.GuiHost; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class CommandStudioDesktopTest { + private IrisSettings previousSettings; + private boolean previousSuppressed; + private RecordingSender sender; + private CommandStudio command; + + @Before + public void setup() { + previousSettings = IrisSettings.settings; + previousSuppressed = GuiHost.isDesktopSuppressed(); + IrisSettings.settings = new IrisSettings(); + IrisSettings.settings.getGui().setUseServerLaunchedGuis(true); + GuiHost.suppressDesktop(true); + sender = new RecordingSender(); + command = new TestCommand(sender); + } + + @After + public void restore() { + IrisSettings.settings = previousSettings; + GuiHost.suppressDesktop(previousSuppressed); + } + + @Test + public void mapReportsMissingDisplayBeforeAccessingWorld() { + command.map(null); + + assertDisplayMessage(); + } + + @Test + public void imageMapReportsMissingDisplayInsteadOfDisabledSetting() { + command.imagemap(null); + + assertDisplayMessage(); + } + + private void assertDisplayMessage() { + assertEquals(1, sender.messages.size()); + assertTrue(sender.messages.getFirst(), sender.messages.getFirst().contains("display")); + } + + private static final class TestCommand extends CommandStudio { + private final VolmitSender sender; + + private TestCommand(VolmitSender sender) { + this.sender = sender; + } + + @Override + public VolmitSender sender() { + return sender; + } + } + + private static final class RecordingSender extends VolmitSender { + private final List messages = new ArrayList<>(); + + private RecordingSender() { + super(null); + } + + @Override + public void sendMessage(String message) { + messages.add(message); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java index c763716bc..6aef135b0 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java @@ -60,10 +60,11 @@ final class EngineHotloader { hotloadSilently(); return; } + engine.awaitNativeStructureBootstrap("complex hotload"); synchronized (engine.lifecycleLock) { engine.requireRunning("rebuild the biome complex"); requireMutableGenerationRuntime("rebuild the biome complex"); - engine.awaitNativeStructureBootstrap("complex hotload"); + engine.requireNativeStructureBootstrapComplete("complex hotload"); engine.lifecycleState = LifecycleState.HOTLOADING; EngineRuntime previous = engine.runtime; GenerationRuntime previousGeneration = previous.generation(); @@ -133,10 +134,11 @@ final class EngineHotloader { hotloadStudio(router); return; } + engine.awaitNativeStructureBootstrap("hotload"); synchronized (engine.lifecycleLock) { engine.requireRunning("hotload"); requireMutableGenerationRuntime("hotload"); - engine.awaitNativeStructureBootstrap("hotload"); + engine.requireNativeStructureBootstrapComplete("hotload"); engine.lifecycleState = LifecycleState.HOTLOADING; EngineRuntime previousRuntime = engine.runtime; IrisDimension previousDimension = engine.getDimension(); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineShutdownSequence.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineShutdownSequence.java index acf862713..64c44e2ca 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineShutdownSequence.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineShutdownSequence.java @@ -70,7 +70,10 @@ final class EngineShutdownSequence { if (!engine.beginShutdown()) { return; } - Throwable routerFailure = stopWorldManager(); + Throwable routerFailure = runCleanup(null, engine::drainNativeStructureBootstrap); + if (routerFailure == null) { + routerFailure = stopWorldManager(); + } if (routerFailure == null) { routerFailure = runCleanup(null, engine::closeAttachedGenerationHistoryRuntimeRouter); } @@ -86,7 +89,6 @@ final class EngineShutdownSequence { if (engine.closed) { return; } - engine.awaitNativeStructureBootstrap("close"); engine.lifecycleState = LifecycleState.CLOSING; engine.getClosing().set(true); engine.backgroundTasks.closeBackgroundTaskAdmission(); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index c8e2ac215..b1bf6f2dd 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java @@ -515,6 +515,21 @@ public class IrisEngine implements Engine { nativeStructureBootstrapBarrier.await(transition); } + void requireNativeStructureBootstrapComplete(String transition) { + if (nativeStructureBootstrapBarrier.isActive()) { + throw new IllegalStateException("Native structure bootstrap started while preparing " + + transition + "; retry after structure initialization completes."); + } + } + + void drainNativeStructureBootstrap() { + Throwable failure = nativeStructureBootstrapBarrier.awaitForClose(); + if (failure != null) { + IrisLogging.reportError("Native structure bootstrap failed for " + getWorld().name() + + "; its workers have stopped and engine cleanup will continue.", failure); + } + } + @Override public void generateMatter(int x, int z, boolean multicore, ChunkContext context) { awaitGenerationCacheWarm(); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/NativeStructureBootstrapBarrier.java b/core/src/main/java/art/arcane/iris/generation/runtime/NativeStructureBootstrapBarrier.java index 2b0aebd45..002afd1df 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/NativeStructureBootstrapBarrier.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/NativeStructureBootstrapBarrier.java @@ -31,7 +31,7 @@ final class NativeStructureBootstrapBarrier { starter.get(), "Native structure bootstrap completion"); completion.whenComplete((ignored, failure) -> completeBridge(bridge, failure)); - return bridge; + return bridge.copy(); } catch (Throwable failure) { poisoned = bridge; bridge.completeExceptionally(failure); @@ -45,32 +45,18 @@ final class NativeStructureBootstrapBarrier { } } - synchronized void await(String transition) { - CompletableFuture completion = active; - if (completion == null) { - return; - } - try { - completion.get(TRANSITION_TIMEOUT_SECONDS, TimeUnit.SECONDS); - active = null; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + void await(String transition) { + Throwable failure = awaitCompletion(transition); + if (failure != null) { throw new IllegalStateException( - "Interrupted while waiting for native structure bootstrap before " + transition + ".", e); - } catch (ExecutionException e) { - if (poisoned != completion) { - active = null; - } - Throwable cause = e.getCause() == null ? e : e.getCause(); - throw new IllegalStateException( - "Native structure bootstrap failed before " + transition + ".", cause); - } catch (TimeoutException e) { - throw new IllegalStateException( - "Native structure bootstrap did not finish before " + transition + " within " - + TRANSITION_TIMEOUT_SECONDS + " seconds.", e); + "Native structure bootstrap failed before " + transition + ".", failure); } } + Throwable awaitForClose() { + return awaitCompletion("close"); + } + synchronized boolean isActive() { return active != null; } @@ -79,6 +65,45 @@ final class NativeStructureBootstrapBarrier { return poisoned != null; } + private Throwable awaitCompletion(String transition) { + CompletableFuture completion; + synchronized (this) { + completion = active; + } + if (completion == null) { + return null; + } + try { + completion.get(TRANSITION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + clearCompleted(completion); + return null; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while waiting for native structure bootstrap before " + transition + ".", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause() == null ? e : e.getCause(); + synchronized (this) { + if (poisoned == completion) { + throw new IllegalStateException( + "Native structure bootstrap could not be tracked before " + transition + ".", cause); + } + clearCompleted(completion); + } + return cause; + } catch (TimeoutException e) { + throw new IllegalStateException( + "Native structure bootstrap did not finish before " + transition + " within " + + TRANSITION_TIMEOUT_SECONDS + " seconds.", e); + } + } + + private synchronized void clearCompleted(CompletableFuture completion) { + if (active == completion) { + active = null; + } + } + private void completeBridge(CompletableFuture bridge, Throwable failure) { if (failure == null) { bridge.complete(null); diff --git a/core/src/main/java/art/arcane/iris/localization/BukkitCommandMessagesExtended.java b/core/src/main/java/art/arcane/iris/localization/BukkitCommandMessagesExtended.java index dc7784bb8..15b602264 100644 --- a/core/src/main/java/art/arcane/iris/localization/BukkitCommandMessagesExtended.java +++ b/core/src/main/java/art/arcane/iris/localization/BukkitCommandMessagesExtended.java @@ -699,6 +699,10 @@ public final class BukkitCommandMessagesExtended { "iris.bukkit.commandstudio.you_must_have_server_launched_guis_enabled_settings", C.RED + "You must have server launched GUIs enabled in the settings!" ); + public static final TextKey COMMAND_STUDIO_DESKTOP_UNAVAILABLE = TextKey.of( + "iris.bukkit.commandstudio.desktop_unavailable", + C.RED + "This server has no available desktop display. Studio GUIs open on the server, so run Iris in a graphical desktop session." + ); public static final TextKey COMMAND_STUDIO_PLAYERS_ONLY = TextKey.of( "iris.bukkit.commandstudio.players_only", C.RED + "Players only!" @@ -973,6 +977,7 @@ public final class BukkitCommandMessagesExtended { COMMAND_STUDIO_CAPTURING_IGENDATA_FROM_NEARBY_CHUNKS, COMMAND_STUDIO_REPORTED, COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS, + COMMAND_STUDIO_DESKTOP_UNAVAILABLE, COMMAND_STUDIO_PLAYERS_ONLY, COMMAND_STUDIO_NO_STUDIO_WORLD_IS_OPEN_2, COMMAND_STUDIO_YOU_MUST_BE_STUDIO_WORLD, diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionListener.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionListener.java index 099e2d53e..1c59620ca 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionListener.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionListener.java @@ -2,10 +2,12 @@ package art.arcane.iris.studio.jigsaw; import art.arcane.iris.studio.jigsaw.JigsawStudioService.ActiveStudio; import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.event.ProtectionProbe; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.BlockState; import org.bukkit.entity.Player; +import org.bukkit.event.Event; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -161,8 +163,13 @@ final class JigsawStudioProtectionListener implements Listener { } } - @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + @EventHandler(priority = EventPriority.LOWEST) public void onUseTool(PlayerInteractEvent event) { + if (ProtectionProbe.isProbe(event) + || event.useItemInHand() == Event.Result.DENY + || (event.getClickedBlock() != null && event.useInteractedBlock() == Event.Result.DENY)) { + return; + } if (event.getHand() != EquipmentSlot.HAND || (event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK)) { @@ -299,6 +306,9 @@ final class JigsawStudioProtectionListener implements Listener { return; } event.setCancelled(true); + if (ProtectionProbe.isProbe(event)) { + return; + } ActiveStudio studio = service.studios.get(event.getPlayer().getWorld().getUID()); if (!authorizeOwner(event.getPlayer(), studio)) { return; @@ -501,6 +511,9 @@ final class JigsawStudioProtectionListener implements Listener { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onPlayerInteract(PlayerInteractEvent event) { + if (ProtectionProbe.isProbe(event)) { + return; + } if (event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.PHYSICAL) { return; } diff --git a/core/src/main/java/art/arcane/iris/studio/object/ObjectStudioSaveService.java b/core/src/main/java/art/arcane/iris/studio/object/ObjectStudioSaveService.java index d92b5558e..8ad484500 100644 --- a/core/src/main/java/art/arcane/iris/studio/object/ObjectStudioSaveService.java +++ b/core/src/main/java/art/arcane/iris/studio/object/ObjectStudioSaveService.java @@ -18,6 +18,7 @@ package art.arcane.iris.studio.object; +import art.arcane.volmlib.util.event.ProtectionProbe; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; @@ -180,6 +181,9 @@ public class ObjectStudioSaveService implements IrisService { @EventHandler(ignoreCancelled = true) public void onPlayerInteract(PlayerInteractEvent event) { + if (ProtectionProbe.isProbe(event)) { + return; + } if (event.getHand() != EquipmentSlot.HAND) return; Action action = event.getAction(); if (action != Action.RIGHT_CLICK_BLOCK && action != Action.LEFT_CLICK_BLOCK) return; diff --git a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java index 2247fb8aa..4acd70858 100644 --- a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java @@ -47,16 +47,14 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.BooleanSupplier; import java.util.function.Consumer; import java.util.function.Supplier; public final class StudioOpenCoordinator { - private static final long STUDIO_CLOSE_TIMEOUT_SECONDS = 120L; - private static final long STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS = 30L; + private static final long STUDIO_CLOSE_WARNING_SECONDS = 120L; private static volatile StudioOpenCoordinator instance; private StudioOpenCoordinator() { @@ -136,6 +134,7 @@ public final class StudioOpenCoordinator { private void executeOpen(StudioOpenRequest request, CompletableFuture future) { World world = null; PlatformChunkGenerator provider = null; + CompletableFuture entryBootstrap = null; try { long openStart = System.nanoTime(); long t = openStart; @@ -166,15 +165,14 @@ public final class StudioOpenCoordinator { } World entryWorld = world; PlatformChunkGenerator entryProvider = provider; - CompletableFuture entryBootstrap = J.afut( - () -> endStudioEntryBootstrap(entryWorld, entryProvider)); + entryBootstrap = endStudioEntryBootstrap(entryWorld, entryProvider); updateStage(request, "apply_world_rules", 0.72D); final World rulesWorld = world; CompletableFuture rulesApplied = J.sfut(() -> WorldRuntimeControlService.get().applyStudioWorldRules(rulesWorld)); if (rulesApplied != null) { - rulesApplied.get(15L, TimeUnit.SECONDS); + rulesApplied.get(); } t = logStudioPhase(request, "apply_world_rules", t, openStart); @@ -191,7 +189,7 @@ public final class StudioOpenCoordinator { t = logStudioPhase(request, "resolve_entry_anchor", t, openStart); updateStage(request, "prepare_structure_rings", 0.79D); - entryBootstrap.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + entryBootstrap.get(); t = logStudioPhase(request, "prepare_structure_rings", t, openStart); updateStage(request, "prepare_generation_caches", 0.88D); @@ -251,6 +249,9 @@ public final class StudioOpenCoordinator { + elapsedMillis(openStart) + "ms"); future.complete(new StudioOpenResult(world, entryLocation)); } catch (Throwable e) { + if (entryBootstrap != null) { + entryBootstrap.handle((ignored, failure) -> null).join(); + } abandonStudioEntryBootstrap(world, e); IrisLogging.reportError("Studio open failed for world \"" + request.worldName() + "\".", e); if (!request.retainOnFailure()) { @@ -269,7 +270,7 @@ public final class StudioOpenCoordinator { request.worldName(), world, request.project()); - cleanup.get(45L, TimeUnit.SECONDS); + cleanup.get(); } catch (Throwable cleanupError) { IrisLogging.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", unwrapFailure(cleanupError)); @@ -317,7 +318,7 @@ public final class StudioOpenCoordinator { } private void runOpenFinalizer(Consumer finalizer, World world) - throws InterruptedException, ExecutionException, TimeoutException { + throws InterruptedException, ExecutionException { if (finalizer == null) { return; } @@ -327,7 +328,7 @@ public final class StudioOpenCoordinator { } CompletableFuture completion = J.sfut(() -> finalizer.accept(world)); - completion.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + completion.get(); } private long elapsedMillis(long startedAtNanos) { @@ -342,60 +343,23 @@ public final class StudioOpenCoordinator { duration); } - private void endStudioEntryBootstrap(World world, PlatformChunkGenerator provider) { + private CompletableFuture endStudioEntryBootstrap(World world, PlatformChunkGenerator provider) { if (!(provider instanceof BukkitChunkGenerator bukkitGenerator)) { - throw new IllegalStateException("Studio runtime provider cannot finish its entry bootstrap."); + return CompletableFuture.failedFuture(new IllegalStateException( + "Studio runtime provider cannot finish its entry bootstrap.")); } - AtomicBoolean activationClaim = new AtomicBoolean(true); CompletableFuture> scheduledActivation = J.sfut(() -> { - if (!activationClaim.compareAndSet(true, false)) { - INMS.get().abandonStudioStructureBootstrap(world); - return CompletableFuture.failedFuture(new IllegalStateException( - "Studio native structure activation was cancelled before it began.")); - } try { - CompletableFuture nativeActivation = - INMS.get().completeStudioStructureBootstrap(world); - if (nativeActivation == null) { - return CompletableFuture.failedFuture(new IllegalStateException( - "Studio native structure activation returned no completion future.")); - } - return nativeActivation; + return Objects.requireNonNull(INMS.get().completeStudioStructureBootstrap(world), + "Studio native structure activation completion"); } catch (ReflectiveOperationException e) { return CompletableFuture.failedFuture(new IllegalStateException( "Studio native structure state could not be activated after entry bootstrap.", e)); } }); - CompletableFuture activation = scheduledActivation + return scheduledActivation .thenCompose(nativeActivation -> nativeActivation) .thenCompose(ignored -> J.sfut(bukkitGenerator::endStudioEntryBootstrap)); - try { - activation.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (InterruptedException e) { - activationClaim.compareAndSet(true, false); - Thread.currentThread().interrupt(); - throw new IllegalStateException("Studio native structure activation was interrupted.", e); - } catch (ExecutionException e) { - activationClaim.compareAndSet(true, false); - throw new IllegalStateException("Studio native structure activation did not complete.", - unwrapFailure(e)); - } catch (TimeoutException e) { - if (!activationClaim.compareAndSet(true, false)) { - try { - activation.get(5L, TimeUnit.SECONDS); - return; - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - throw new IllegalStateException( - "Studio native structure activation was interrupted.", interrupted); - } catch (ExecutionException | TimeoutException settlementFailure) { - throw new IllegalStateException( - "Studio native structure activation did not settle after claiming completion.", - unwrapFailure(settlementFailure)); - } - } - throw new IllegalStateException("Studio native structure activation did not complete.", e); - } } private void abandonStudioEntryBootstrap(World world, Throwable failure) { @@ -413,12 +377,12 @@ public final class StudioOpenCoordinator { CompletableFuture abandonment = J.sfut( () -> INMS.get().abandonStudioStructureBootstrap(world)); try { - abandonment.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + abandonment.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); failure.addSuppressed(new IllegalStateException( "Studio native structure abandonment was interrupted.", e)); - } catch (ExecutionException | TimeoutException e) { + } catch (ExecutionException e) { failure.addSuppressed(new IllegalStateException( "Studio native structure abandonment did not complete.", unwrapFailure(e))); } @@ -475,7 +439,15 @@ public final class StudioOpenCoordinator { failure )); } - return closeFuture.whenComplete((result, throwable) -> lease.close()); + return closeFuture.whenComplete((result, throwable) -> { + if (throwable == null && result.failureCause() == null) { + lease.close(); + } else { + IrisLogging.error("Studio cleanup remains incomplete for \"" + operationTarget + + "\". Further Iris world operations are blocked. Resolve the reported failure " + + "and restart the server manually before opening another world."); + } + }).copy(); } private CompletableFuture closeWorldReserved( @@ -486,69 +458,37 @@ public final class StudioOpenCoordinator { IrisProject project ) { AtomicBoolean unloadConfirmed = new AtomicBoolean(false); - AtomicBoolean unloadStarted = new AtomicBoolean(false); AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder); - AtomicBoolean terminalTimeout = new AtomicBoolean(false); if (world != null) { IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); } CompletableFuture sequence = sequenceStudioClose( () -> evacuateWorldFamily(worldName, world), - () -> { - unloadStarted.set(true); - return unloadWorldFamily(worldName, world).thenRun(() -> { - if (terminalTimeout.get()) { - throw new CompletionException(new TimeoutException( - "Studio close stopped after its terminal timeout.")); - } - unloadConfirmed.set(true); - if (project != null) { - project.setActiveProvider(null); - project.setActiveOpenKind(null); - } - }); - }, + () -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)), () -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(), () -> deleteFolder ? deleteWorldFamily(worldName).thenRun(() -> folderDeleted.set(true)) - : CompletableFuture.completedFuture(null), - terminalTimeout::get + : CompletableFuture.completedFuture(null) ); - CompletableFuture operation = guardCloseCompletion( - sequence, - terminalTimeout, - worldName) - .thenApply(ignored -> new StudioCloseResult( - worldName, - true, - folderDeleted.get(), - false, - null - )) + warnOnSlowClose(sequence, worldName, + CompletableFuture.delayedExecutor(STUDIO_CLOSE_WARNING_SECONDS, TimeUnit.SECONDS)); + return sequence.thenApply(ignored -> { + if (project != null) { + project.setActiveProvider(null); + project.setActiveOpenKind(null); + } + if (world != null) { + IrisToolbelt.endWorldMaintenance(world, "studio-close", true); + } + return new StudioCloseResult(worldName, true, folderDeleted.get(), false, null); + }) .exceptionally(throwable -> { Throwable failure = unwrapFailure(throwable); - requestRestartAfterPartialClose(worldName, unloadStarted.get()); boolean queued = deleteFolder && queueStartupCleanup(worldName, failure); return new StudioCloseResult( - worldName, - unloadConfirmed.get(), - folderDeleted.get(), - queued, - failure - ); + worldName, unloadConfirmed.get(), folderDeleted.get(), queued, failure); }); - return operation.whenComplete((result, throwable) -> { - if (world != null) { - IrisToolbelt.endWorldMaintenance(world, "studio-close", true); - } - }); - } - - static void requestRestartAfterPartialClose(String worldName, boolean unloadStarted) { - if (unloadStarted) { - ServerConfigurator.restart("Studio close failed after world unload began for \"" + worldName + "\"."); - } } static CompletableFuture sequenceStudioClose( @@ -556,75 +496,28 @@ public final class StudioOpenCoordinator { Supplier> unload, Supplier> closeGenerator, Supplier> deleteFolders - ) { - return sequenceStudioClose(evacuate, unload, closeGenerator, deleteFolders, () -> false); - } - - static CompletableFuture sequenceStudioClose( - Supplier> evacuate, - Supplier> unload, - Supplier> closeGenerator, - Supplier> deleteFolders, - BooleanSupplier terminalTimeout ) { return invokePhase(evacuate) - .thenCompose(ignored -> invokePhaseUnlessTimedOut(unload, terminalTimeout)) - .thenCompose(ignored -> invokePhaseUnlessTimedOut(closeGenerator, terminalTimeout)) - .thenCompose(ignored -> invokePhaseUnlessTimedOut(deleteFolders, terminalTimeout)); + .thenCompose(ignored -> invokePhase(unload)) + .thenCompose(ignored -> invokePhase(closeGenerator)) + .thenCompose(ignored -> invokePhase(deleteFolders)); } private static CompletableFuture invokePhase(Supplier> phase) { try { - CompletableFuture future = phase.get(); - if (future == null) { - return CompletableFuture.failedFuture(new IllegalStateException("Studio close phase returned no completion future.")); - } - return future; + return Objects.requireNonNull(phase.get(), "Studio close phase completion"); } catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } } - private static CompletableFuture invokePhaseUnlessTimedOut( - Supplier> phase, - BooleanSupplier terminalTimeout - ) { - if (terminalTimeout.getAsBoolean()) { - return CompletableFuture.failedFuture(new TimeoutException( - "Studio close stopped after its terminal timeout.")); - } - return invokePhase(phase); - } - - private CompletableFuture guardCloseCompletion( - CompletableFuture source, - AtomicBoolean terminalTimeout, - String worldName - ) { - CompletableFuture guarded = new CompletableFuture<>(); - AtomicBoolean settled = new AtomicBoolean(false); - source.whenComplete((ignored, throwable) -> { - if (!settled.compareAndSet(false, true)) { - return; - } - if (throwable == null) { - guarded.complete(null); - } else { - guarded.completeExceptionally(throwable); + static void warnOnSlowClose(CompletableFuture completion, String worldName, Executor warningExecutor) { + warningExecutor.execute(() -> { + if (!completion.isDone()) { + IrisLogging.warn("Studio cleanup is still running for \"" + worldName + + "\". Iris is waiting for active work before releasing world resources."); } }); - CompletableFuture.delayedExecutor(STUDIO_CLOSE_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> { - if (!settled.compareAndSet(false, true)) { - return; - } - terminalTimeout.set(true); - TimeoutException timeout = new TimeoutException( - "Studio close did not settle within " + STUDIO_CLOSE_TIMEOUT_SECONDS - + " seconds for \"" + worldName + "\"."); - ServerConfigurator.restart("Studio close timed out for \"" + worldName + "\"."); - guarded.completeExceptionally(timeout); - }); - return guarded; } private CompletableFuture evacuateWorldFamily(String worldName, World primaryWorld) { @@ -653,7 +546,7 @@ public final class StudioOpenCoordinator { CompletableFuture unload = J.sfut(() -> IrisServices.get(MultiverseCoreLink.class) .removeIfPresent(loadedWorld)) - .thenCompose(ignored -> WorldLifecycleService.get().unloadAsync(loadedWorld, false)); + .thenCompose(ignored -> WorldLifecycleService.get().unloadSettledAsync(loadedWorld, false)); unloads.add(unload); } @@ -743,7 +636,7 @@ public final class StudioOpenCoordinator { null, true, null - ).get(30L, TimeUnit.SECONDS); + ).get(); if (cleanupResult.failureCause() != null) { IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", cleanupResult.failureCause()); } diff --git a/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java b/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java index f3038d5db..d07a0d481 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java +++ b/core/src/main/java/art/arcane/iris/studio/view/ImageMapStudioGUI.java @@ -159,7 +159,14 @@ public final class ImageMapStudioGUI { } public static void launch(Engine engine) { - EventQueue.invokeLater(() -> new ImageMapStudioGUI(engine).show()); + if (!GuiHost.isAvailable()) { + return; + } + EventQueue.invokeLater(() -> { + if (GuiHost.isAvailable()) { + new ImageMapStudioGUI(engine).show(); + } + }); } static void reloadActiveEngine(Engine engine) { diff --git a/core/src/main/java/art/arcane/iris/studio/view/NoiseExplorerGUI.java b/core/src/main/java/art/arcane/iris/studio/view/NoiseExplorerGUI.java index 0d4d2c9cc..1d424a496 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/NoiseExplorerGUI.java +++ b/core/src/main/java/art/arcane/iris/studio/view/NoiseExplorerGUI.java @@ -216,17 +216,29 @@ public final class NoiseExplorerGUI extends JPanel { } public static void launch() { + if (!GuiHost.isAvailable()) { + return; + } Engine engine = GuiHost.get().findActiveEngine(); EventQueue.invokeLater(() -> { + if (!GuiHost.isAvailable()) { + return; + } NoiseExplorerGUI explorer = new NoiseExplorerGUI(engine, null, null, null, DEFAULT_SEED, null, null); buildFrame(IrisLanguage.plain(DesktopUiMessages.NOISE_TITLE), explorer); }); } public static void launchGeneratorKey(String generatorKey, IrisGenerator fallbackGenerator, long initialSeed) { + if (!GuiHost.isAvailable()) { + return; + } Engine engine = GuiHost.get().findActiveEngine(); String displayName = generatorKey == null || generatorKey.isBlank() ? "Custom Generator" : generatorKey; EventQueue.invokeLater(() -> { + if (!GuiHost.isAvailable()) { + return; + } NoiseExplorerGUI explorer = new NoiseExplorerGUI( engine, generatorKey, diff --git a/core/src/main/java/art/arcane/iris/studio/view/VisionGUI.java b/core/src/main/java/art/arcane/iris/studio/view/VisionGUI.java index 291fbd9ed..be761bc3b 100644 --- a/core/src/main/java/art/arcane/iris/studio/view/VisionGUI.java +++ b/core/src/main/java/art/arcane/iris/studio/view/VisionGUI.java @@ -209,10 +209,16 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi } public static void launch(Engine engine, UUID openerId) { + if (!GuiHost.isAvailable()) { + return; + } EventQueue.invokeLater(() -> createAndShowGUI(engine, openerId)); } private static void createAndShowGUI(Engine engine, UUID openerId) { + if (!GuiHost.isAvailable()) { + return; + } JFrame frame = new JFrame(IrisLanguage.plain(DesktopUiMessages.VISION_TITLE)); GuiHost.prepareFrame(frame); VisionGUI vision = new VisionGUI(frame, engine, openerId); diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/WorldLifecycleService.java b/core/src/main/java/art/arcane/iris/world/lifecycle/WorldLifecycleService.java index 44f4afa46..ed142f3e8 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/WorldLifecycleService.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/WorldLifecycleService.java @@ -123,6 +123,11 @@ public final class WorldLifecycleService { } public CompletableFuture unloadAsync(World world, boolean save) { + World requiredWorld = Objects.requireNonNull(world, "world"); + return guardUnloadCompletion(requiredWorld.getName(), unloadSettledAsync(requiredWorld, save)); + } + + public CompletableFuture unloadSettledAsync(World world, boolean save) { World requiredWorld = Objects.requireNonNull(world, "world"); String worldIdentity = WorldIdentity.serialize(requiredWorld); String worldName = requiredWorld.getName(); @@ -150,8 +155,7 @@ public final class WorldLifecycleService { unloadFuture.whenComplete((unloaded, throwable) -> WorldUnloadBoundaryRegistry.complete(rawBoundary, unloaded, throwable)); - CompletableFuture guardedFuture = guardUnloadCompletion(worldName, unloadFuture); - return guardedFuture.whenComplete((unloaded, throwable) -> { + return unloadFuture.whenComplete((unloaded, throwable) -> { if (throwable != null) { Throwable cause = WorldLifecycleSupport.unwrap(throwable); IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + worldName @@ -162,7 +166,7 @@ public final class WorldLifecycleService { if (Boolean.TRUE.equals(unloaded)) { worldBackendByKey.remove(worldIdentity, backend.backendName()); } - }); + }).copy(); } private CompletableFuture guardUnloadCompletion( diff --git a/core/src/main/resources/languages/de_DE.toml b/core/src/main/resources/languages/de_DE.toml index 3629bca73..fd3c6ae2e 100644 --- a/core/src/main/resources/languages/de_DE.toml +++ b/core/src/main/resources/languages/de_DE.toml @@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Du musst in einer Iris-Welt sein." capturing_igendata_from_nearby_chunks = "IGenData wird aus {value} nahe gelegenen Chunks erfasst." reported = "Gemeldet an: {value}" you_must_have_server_launched_guis_enabled_settings = "§cIn den Einstellungen müssen vom Server gestartete GUIs aktiviert sein!" +desktop_unavailable = "§cDieser Server hat keine verfügbare Desktop-Anzeige. Die Studio-Fenster öffnen sich auf dem Server. Starte Iris in einer grafischen Desktop-Sitzung." players_only = "§cNur Spieler!" no_studio_world_is_open_2 = "§cKeine Studio-Welt ist offen!" you_must_be_studio_world = "§cDu musst in einer Studiowelt sein!" diff --git a/core/src/main/resources/languages/es_ES.toml b/core/src/main/resources/languages/es_ES.toml index 6b2e958b6..aa4a1d004 100644 --- a/core/src/main/resources/languages/es_ES.toml +++ b/core/src/main/resources/languages/es_ES.toml @@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Debes estar en un mundo de Iris." capturing_igendata_from_nearby_chunks = "Capturando IGenData de {value} chunks cercanos." reported = "Informe escrito en: {value}" you_must_have_server_launched_guis_enabled_settings = "§c¡Debes habilitar en los ajustes las GUI iniciadas por el servidor!" +desktop_unavailable = "§cEste servidor no dispone de una pantalla de escritorio. Las ventanas de Studio se abren en el servidor. Ejecuta Iris en una sesión de escritorio gráfica." players_only = "§c¡Solo jugadores!" no_studio_world_is_open_2 = "§c¡No hay ningún mundo de Studio abierto!" you_must_be_studio_world = "§c¡Debes estar en un mundo de Studio!" diff --git a/core/src/main/resources/languages/fi_FI.toml b/core/src/main/resources/languages/fi_FI.toml index 9b69629d8..fa35bf6c0 100644 --- a/core/src/main/resources/languages/fi_FI.toml +++ b/core/src/main/resources/languages/fi_FI.toml @@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Olet varmaan iris-maailmassa." capturing_igendata_from_nearby_chunks = "Kaappaus IGenData alkaen {value} Lähellä on chunkia." reported = "Raportoitu: {value}" you_must_have_server_launched_guis_enabled_settings = "§cSinulla täytyy olla palvelin käynnistetty GUIs käytössä asetuksissa!" +desktop_unavailable = "§cTällä palvelimella ei ole käytettävissä työpöytänäyttöä. Studion ikkunat avautuvat palvelimella. Käynnistä Iris graafisessa työpöytäistunnossa." players_only = "§cVain pelaajille!" no_studio_world_is_open_2 = "§cYksikään studiomaailma ei ole auki!" you_must_be_studio_world = "§cOlet varmaan studiomaailmassa!" diff --git a/core/src/main/resources/languages/fr_FR.toml b/core/src/main/resources/languages/fr_FR.toml index acd59d9f8..ead5f8100 100644 --- a/core/src/main/resources/languages/fr_FR.toml +++ b/core/src/main/resources/languages/fr_FR.toml @@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Vous devez être dans un monde Iris." capturing_igendata_from_nearby_chunks = "Capture d'IGenData depuis {value} chunks à proximité." reported = "Rapport écrit dans : {value}" you_must_have_server_launched_guis_enabled_settings = "§cVous devez activer les GUI lancées par le serveur dans les paramètres !" +desktop_unavailable = "§cCe serveur ne dispose pas d’un affichage de bureau. Les fenêtres de Studio s’ouvrent sur le serveur. Lancez Iris dans une session de bureau graphique." players_only = "§cJoueurs uniquement !" no_studio_world_is_open_2 = "§cAucun monde Studio n'est ouvert !" you_must_be_studio_world = "§cVous devez être dans un monde Studio !" diff --git a/core/src/main/resources/languages/he_IL.toml b/core/src/main/resources/languages/he_IL.toml index 8dd436c53..de46a6162 100644 --- a/core/src/main/resources/languages/he_IL.toml +++ b/core/src/main/resources/languages/he_IL.toml @@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "אתה חייב להיות בעולם חג המול capturing_igendata_from_nearby_chunks = "איסוף הנתונים של {value} הסמוך." reported = "דווח על: {value}" you_must_have_server_launched_guis_enabled_settings = "§cאתה חייב לפתוח את השרת GUIs אפשרו בהגדרות!" +desktop_unavailable = "§cאין לשרת הזה תצוגת שולחן עבודה זמינה. חלונות Studio נפתחים בשרת. יש להפעיל את Iris בסביבת שולחן עבודה גרפית." players_only = "§cשחקנים בלבד!" no_studio_world_is_open_2 = "§cאין עולם סטודיו פתוח!" you_must_be_studio_world = "§cאתה חייב להיות בעולם סטודיו!" diff --git a/core/src/main/resources/languages/it_IT.toml b/core/src/main/resources/languages/it_IT.toml index 5bd7a50d2..6874ca343 100644 --- a/core/src/main/resources/languages/it_IT.toml +++ b/core/src/main/resources/languages/it_IT.toml @@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Devi essere in un mondo iris." capturing_igendata_from_nearby_chunks = "Acquisizione di IGenData da {value} chunk vicini." reported = "Segnalato a: {value}" you_must_have_server_launched_guis_enabled_settings = "§cDevi abilitare nelle impostazioni le GUI avviate dal server!" +desktop_unavailable = "§cQuesto server non dispone di uno schermo desktop. Le finestre di Studio si aprono sul server. Avvia Iris in una sessione desktop grafica." players_only = "§cSolo i giocatori!" no_studio_world_is_open_2 = "§cNessun mondo di studio è aperto!" you_must_be_studio_world = "§cDevi essere in uno studio!" diff --git a/core/src/main/resources/languages/ja-JP.toml b/core/src/main/resources/languages/ja-JP.toml index d8aac3525..e8a3f4465 100644 --- a/core/src/main/resources/languages/ja-JP.toml +++ b/core/src/main/resources/languages/ja-JP.toml @@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "Iris ワールド内にいる必要があります capturing_igendata_from_nearby_chunks = "周辺の {value} チャンクから IGenData をキャプチャしています。" reported = "報告: {value}" you_must_have_server_launched_guis_enabled_settings = "§c設定でサーバー起動 GUI を有効にする必要があります!" +desktop_unavailable = "§cこのサーバーには利用可能なデスクトップ画面がありません。Studio のウィンドウはサーバー上で開くため、グラフィカルなデスクトップセッションで Iris を実行してください。" players_only = "§cプレイヤーのみ!" no_studio_world_is_open_2 = "§c開いているスタジオワールドはありません!" you_must_be_studio_world = "§cスタジオワールドでなければいけません!" diff --git a/core/src/main/resources/languages/ko_KR.toml b/core/src/main/resources/languages/ko_KR.toml index 5a2ff0eb2..0b0fd7be9 100644 --- a/core/src/main/resources/languages/ko_KR.toml +++ b/core/src/main/resources/languages/ko_KR.toml @@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "당신은 아이리스 세계에 있어야합니다. capturing_igendata_from_nearby_chunks = "주변 {value}개 청크에서 IGenData를 캡처하는 중입니다." reported = "보고 위치: {value}" you_must_have_server_launched_guis_enabled_settings = "§c서버가 시작되어야 합니다. GUIs 설정에서 활성화!" +desktop_unavailable = "§c이 서버에는 사용 가능한 데스크톱 화면이 없습니다. Studio 창은 서버에서 열리므로 그래픽 데스크톱 세션에서 Iris를 실행하세요." players_only = "§c플레이어 만!" no_studio_world_is_open_2 = "§c스튜디오 세계가 오픈!" you_must_be_studio_world = "§c스튜디오 세계에 있어야합니다!" diff --git a/core/src/main/resources/languages/lt_LT.toml b/core/src/main/resources/languages/lt_LT.toml index 1980fcc7d..0ccd94e8d 100644 --- a/core/src/main/resources/languages/lt_LT.toml +++ b/core/src/main/resources/languages/lt_LT.toml @@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Turbūt esi rainelės pasaulyje." capturing_igendata_from_nearby_chunks = "Gaudymas IGenData nuo {value} Šalia šakutės." reported = "Pranešama apie: {value}" you_must_have_server_launched_guis_enabled_settings = "§cJūs turite paleisti serverį GUIs įjungta nustatymuose!" +desktop_unavailable = "§cŠiame serveryje nėra prieinamo darbalaukio ekrano. Studio langai atidaromi serveryje. Paleiskite Iris grafinėje darbalaukio sesijoje." players_only = "§cTik žaidėjai!" no_studio_world_is_open_2 = "§cJokio studijos pasaulio nėra!" you_must_be_studio_world = "§cJūs turbūt studijuojat!" diff --git a/core/src/main/resources/languages/nl_NL.toml b/core/src/main/resources/languages/nl_NL.toml index 6c61810ca..a73f4352b 100644 --- a/core/src/main/resources/languages/nl_NL.toml +++ b/core/src/main/resources/languages/nl_NL.toml @@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Je moet in een iriswereld zijn." capturing_igendata_from_nearby_chunks = "Vangen IGenData van {value} In de buurt." reported = "Gerapporteerd aan: {value}" you_must_have_server_launched_guis_enabled_settings = "§cU moet de server gestart hebben GUIs ingeschakeld in de instellingen!" +desktop_unavailable = "§cDeze server heeft geen beschikbaar bureaubladscherm. Studio-vensters openen op de server. Start Iris in een grafische bureaubladsessie." players_only = "§cAlleen spelers!" no_studio_world_is_open_2 = "§cGeen enkele studiowereld is open!" you_must_be_studio_world = "§cJe moet in een studio wereld zijn!" diff --git a/core/src/main/resources/languages/pl_PL.toml b/core/src/main/resources/languages/pl_PL.toml index b8f165de4..e2f21fccf 100644 --- a/core/src/main/resources/languages/pl_PL.toml +++ b/core/src/main/resources/languages/pl_PL.toml @@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Musisz być w świecie tęczówki." capturing_igendata_from_nearby_chunks = "Chwytanie IGenData od {value} Pobliskie chunki." reported = "Zgłoszone do: {value}" you_must_have_server_launched_guis_enabled_settings = "§cMusisz mieć serwer uruchomiony GUIs włączone w ustawieniach!" +desktop_unavailable = "§cTen serwer nie ma dostępnego ekranu pulpitu. Okna Studio otwierają się na serwerze. Uruchom Iris w graficznej sesji pulpitu." players_only = "§cTylko gracze!" no_studio_world_is_open_2 = "§cŻadne studio nie jest otwarte!" you_must_be_studio_world = "§cMusisz być w świecie studia!" diff --git a/core/src/main/resources/languages/pt_PT.toml b/core/src/main/resources/languages/pt_PT.toml index e97b883cd..522647681 100644 --- a/core/src/main/resources/languages/pt_PT.toml +++ b/core/src/main/resources/languages/pt_PT.toml @@ -664,6 +664,7 @@ you_must_be_iris_world_2 = "Deves estar num mundo íris." capturing_igendata_from_nearby_chunks = "Captura IGenData de {value} Pedaços próximos." reported = "Comunicado em: {value}" you_must_have_server_launched_guis_enabled_settings = "§cVocê deve ter lançado o servidor GUIs habilitado nas configurações!" +desktop_unavailable = "§cEste servidor não dispõe de um ecrã de ambiente de trabalho. As janelas do Studio abrem no servidor. Execute o Iris numa sessão gráfica de ambiente de trabalho." players_only = "§cSó jogadores!" no_studio_world_is_open_2 = "§cNenhum mundo de estúdio está aberto!" you_must_be_studio_world = "§cDeves estar num mundo de estúdio!" diff --git a/core/src/main/resources/languages/ru_RU.toml b/core/src/main/resources/languages/ru_RU.toml index fe99c5245..3e6afffc8 100644 --- a/core/src/main/resources/languages/ru_RU.toml +++ b/core/src/main/resources/languages/ru_RU.toml @@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Вы должны быть в мире радужн capturing_igendata_from_nearby_chunks = "захватывать IGenData из {value} близлежащих кусков." reported = "Сообщалось: {value}" you_must_have_server_launched_guis_enabled_settings = "§cВы должны запустить сервер GUIs Включено в настройках!" +desktop_unavailable = "§cНа этом сервере недоступен графический рабочий стол. Окна Studio открываются на сервере. Запустите Iris в графическом сеансе рабочего стола." players_only = "§cТолько игроки!" no_studio_world_is_open_2 = "§cНи один студийный мир не открыт!" you_must_be_studio_world = "§cВы должны быть в студии!" diff --git a/core/src/main/resources/languages/tr_TR.toml b/core/src/main/resources/languages/tr_TR.toml index e58252bfd..6c8aca0ee 100644 --- a/core/src/main/resources/languages/tr_TR.toml +++ b/core/src/main/resources/languages/tr_TR.toml @@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Bir iris dünyasında olmalısınız." capturing_igendata_from_nearby_chunks = "Sheturing IGenData -den {value} Yakındaki chunks." reported = "Rapor: {value}" you_must_have_server_launched_guis_enabled_settings = "§cSunucu başlatmış olmalısın GUIs ayarlarda etkinleştirin!" +desktop_unavailable = "§cBu sunucuda kullanılabilir bir masaüstü ekranı yok. Studio pencereleri sunucuda açılır. Iris’i grafik masaüstü oturumunda çalıştırın." players_only = "§cOyuncular sadece!" no_studio_world_is_open_2 = "§cStüdyo dünyası açık değil!" you_must_be_studio_world = "§cBir stüdyo dünyasında olmalısın!" diff --git a/core/src/main/resources/languages/vi_VI.toml b/core/src/main/resources/languages/vi_VI.toml index 398af0493..9afdd189e 100644 --- a/core/src/main/resources/languages/vi_VI.toml +++ b/core/src/main/resources/languages/vi_VI.toml @@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "Chắc cô đang ở thế giới của Iris." capturing_igendata_from_nearby_chunks = "Bắt buộc IGenData từ {value} Gần đây thôi." reported = "Báo cáo: {value}" you_must_have_server_launched_guis_enabled_settings = "§cBạn phải có máy chủ đã chạy GUIs đã bật trong thiết lập." +desktop_unavailable = "§cMáy chủ này không có màn hình đồ họa khả dụng. Cửa sổ Studio mở trên máy chủ. Hãy chạy Iris trong phiên làm việc có giao diện đồ họa." players_only = "§cChỉ người chơi thôi!" no_studio_world_is_open_2 = "§cKhông phòng thu nào mở cửa cả!" you_must_be_studio_world = "§cCậu chắc đang ở trong phòng thu!" diff --git a/core/src/main/resources/languages/zh_CN.toml b/core/src/main/resources/languages/zh_CN.toml index 2ffd05cd9..27ea089df 100644 --- a/core/src/main/resources/languages/zh_CN.toml +++ b/core/src/main/resources/languages/zh_CN.toml @@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "你一定在Iris世界里" capturing_igendata_from_nearby_chunks = "隐藏 IGenData 从 {value} 附近块。" reported = "向: {value}" you_must_have_server_launched_guis_enabled_settings = "§c您必须已启动服务器 GUIs 在设置中启用 !" +desktop_unavailable = "§c此服务器没有可用的桌面显示环境。Studio 窗口会在服务器上打开,请在图形桌面会话中运行 Iris。" players_only = "§c只有球员!" no_studio_world_is_open_2 = "§c没有工作室的世界是开放的!" you_must_be_studio_world = "§c你一定在一个工作室的世界!" diff --git a/core/src/main/resources/languages/zh_TW.toml b/core/src/main/resources/languages/zh_TW.toml index b89fa1099..38bffe2d7 100644 --- a/core/src/main/resources/languages/zh_TW.toml +++ b/core/src/main/resources/languages/zh_TW.toml @@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "你一定在Iris世界裡" capturing_igendata_from_nearby_chunks = "隱藏 IGenData 從 {value} 附近塊。" reported = "向: {value}" you_must_have_server_launched_guis_enabled_settings = "§c您必須已啟動伺服器 GUIs 在設定中啟用 !" +desktop_unavailable = "§c此伺服器沒有可用的桌面顯示環境。Studio 視窗會在伺服器上開啟,請在圖形桌面工作階段中執行 Iris。" players_only = "§c只有球員!" no_studio_world_is_open_2 = "§c沒有工作室的世界是開放的!" you_must_be_studio_world = "§c你一定在一個工作室的世界!" diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java index 8e181e29e..40aa6e440 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java @@ -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 rings = new CompletableFuture<>(); + barrier.start(() -> { + }, () -> rings); + rings.completeExceptionally(new IllegalStateException("Ring worker failed")); + doCallRealMethod().when(fixture.engine).drainNativeStructureBootstrap(); + + try (MockedStatic 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 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(); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java index 5452c2e98..a01a27c30 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java @@ -880,6 +880,7 @@ public class IrisEngineGenerationRuntimeScopeTest { setField(engine, "generationRuntimeRetirementListeners", new CopyOnWriteArraySet()); 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; diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/NativeStructureBootstrapBarrierTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/NativeStructureBootstrapBarrierTest.java index 3841e79af..da7996fae 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/NativeStructureBootstrapBarrierTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/NativeStructureBootstrapBarrierTest.java @@ -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 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 rings = new CompletableFuture<>(); + CompletableFuture 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 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(); diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionProbeTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionProbeTest.java new file mode 100644 index 000000000..5502ac44c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionProbeTest.java @@ -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 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); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolInteractionTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolInteractionTest.java new file mode 100644 index 000000000..11d024fb2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolInteractionTest.java @@ -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) { + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/object/StudioBootstrapActivationTest.java b/core/src/test/java/art/arcane/iris/studio/object/StudioBootstrapActivationTest.java new file mode 100644 index 000000000..aff11caa9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/object/StudioBootstrapActivationTest.java @@ -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 rings = new CompletableFuture<>(); + when(nms.completeStudioStructureBootstrap(world)).thenReturn(rings); + try (MockedStatic binding = mockStatic(INMS.class); + MockedStatic 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 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 activate(World world, PlatformChunkGenerator provider) throws Exception { + Method method = StudioOpenCoordinator.class.getDeclaredMethod( + "endStudioEntryBootstrap", World.class, PlatformChunkGenerator.class); + method.setAccessible(true); + return (CompletableFuture) method.invoke(StudioOpenCoordinator.get(), world, provider); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/object/StudioCloseRecoveryTest.java b/core/src/test/java/art/arcane/iris/studio/object/StudioCloseRecoveryTest.java new file mode 100644 index 000000000..5b01757d9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/object/StudioCloseRecoveryTest.java @@ -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 lifecycle = mockStatic(LifecycleOperationCoordinator.class); + MockedStatic configurator = mockStatic(ServerConfigurator.class); + MockedStatic 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 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 lifecycle = mockStatic(LifecycleOperationCoordinator.class); + MockedStatic configurator = mockStatic(ServerConfigurator.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator); + + CompletableFuture 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 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 lifecycle = mockStatic(LifecycleOperationCoordinator.class); + MockedStatic configurator = mockStatic(ServerConfigurator.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator); + + CompletableFuture 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 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) method.invoke( + StudioOpenCoordinator.get(), provider, null, null, false, project); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java b/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java index b318e956e..d093be1b1 100644 --- a/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java +++ b/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java @@ -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 phases = new ArrayList<>(); - AtomicBoolean terminalTimeout = new AtomicBoolean(false); CompletableFuture unload = new CompletableFuture<>(); + AtomicReference warning = new AtomicReference<>(); + try (MockedStatic configurator = mockStatic(ServerConfigurator.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + CompletableFuture 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 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 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 configurator = mockStatic(ServerConfigurator.class)) { - StudioOpenCoordinator.requestRestartAfterPartialClose("studio-world", false); + assertEquals(List.of("evacuate", "unload", "close-generator", "delete-folders"), phases); configurator.verifyNoInteractions(); } } diff --git a/core/src/test/java/art/arcane/iris/studio/view/DesktopGuiLaunchTest.java b/core/src/test/java/art/arcane/iris/studio/view/DesktopGuiLaunchTest.java new file mode 100644 index 000000000..e8dd3b230 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/view/DesktopGuiLaunchTest.java @@ -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 failure = new AtomicReference<>(); + + @Override + protected void dispatchEvent(AWTEvent event) { + try { + super.dispatchEvent(event); + } catch (Throwable error) { + failure.compareAndSet(null, error); + } + } + + private void remove() { + pop(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/world/lifecycle/WorldLifecycleUnloadAsyncTest.java b/core/src/test/java/art/arcane/iris/world/lifecycle/WorldLifecycleUnloadAsyncTest.java index 4a26062cd..b0ad38d3f 100644 --- a/core/src/test/java/art/arcane/iris/world/lifecycle/WorldLifecycleUnloadAsyncTest.java +++ b/core/src/test/java/art/arcane/iris/world/lifecycle/WorldLifecycleUnloadAsyncTest.java @@ -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 timeouts = new ArrayList<>(); + Executor captureTimeout = timeouts::add; + + try (MockedStatic futures = mockStatic(CompletableFuture.class, CALLS_REAL_METHODS)) { + futures.when(() -> CompletableFuture.delayedExecutor(120L, TimeUnit.SECONDS)) + .thenReturn(captureTimeout); + + CompletableFuture result = service.unloadSettledAsync(world, false); + CompletionStage 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 result = service.unloadSettledAsync(world, false); + CompletionStage 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 timeouts = new ArrayList<>(); + Executor captureTimeout = timeouts::add; + + try (MockedStatic futures = mockStatic(CompletableFuture.class, CALLS_REAL_METHODS); + MockedStatic configurator = mockStatic(ServerConfigurator.class); + MockedStatic toolbelt = mockStatic(IrisToolbelt.class)) { + futures.when(() -> CompletableFuture.delayedExecutor(120L, TimeUnit.SECONDS)) + .thenReturn(captureTimeout); + + CompletableFuture result = service.unloadAsync(world, false); + CompletionStage 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