Command Corrections.

This commit is contained in:
Brian Neumann-Fopiano
2026-09-17 04:48:35 -04:00
parent 1e1eaa96aa
commit b18681a3e2
43 changed files with 1227 additions and 251 deletions
@@ -122,7 +122,7 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoPOI(e, type, player(), teleport); 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( public void river(
@Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class) @Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class)
String type, String type,
@@ -147,6 +147,7 @@ public class CommandFind implements DirectorExecutor {
return; return;
} }
Location origin = target.getLocation(); Location origin = target.getLocation();
World originWorld = origin.getWorld();
int requestedDistance = runtime == null int requestedDistance = runtime == null
? 8192 ? 8192
: Math.min(8192, runtime.settings().routing().tileSize() * 15); : Math.min(8192, runtime.settings().routing().tileSize() * 15);
@@ -179,9 +180,9 @@ public class CommandFind implements DirectorExecutor {
return; return;
} }
Location destination = new Location( Location destination = new Location(
target.getWorld(), feature.x(), feature.y(), feature.z()); originWorld, feature.x(), feature.y(), feature.z());
prepareStructureTeleport( prepareStructureTeleport(
target, target.getWorld(), commandSender, label, destination, true); target, originWorld, commandSender, label, destination, true);
} catch (Throwable error) { } catch (Throwable error) {
sendStructureMessage(target, commandSender, sendStructureMessage(target, commandSender,
C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName()); 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, private void prepareStructureTeleport(Player target, World world, VolmitSender commandSender, String structure,
Location at, boolean useLocatedY) { Location at, boolean useLocatedY) {
int chunkX = at.getBlockX() >> 4; J.runEntity(target, () -> {
int chunkZ = at.getBlockZ() >> 4; if (!target.isOnline()) {
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; return;
} }
boolean scheduled = J.runRegion(world, chunkX, chunkZ, if (!world.equals(target.getWorld())) {
() -> teleportToStructure(target, world, commandSender, structure, at, useLocatedY)); commandSender.sendMessage(C.YELLOW + "Cancelled teleport to " + structure + " because your world changed.");
if (!scheduled) { return;
sendStructureMessage(target, commandSender, C.RED + "Could not schedule the destination lookup for " + structure + ".");
} }
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, private void teleportToStructure(Player target, World world, VolmitSender commandSender, String structure,
Location at, boolean useLocatedY) { Location at, boolean useLocatedY) {
try { try {
@@ -760,16 +780,41 @@ public class CommandFind implements DirectorExecutor {
? Math.max(world.getMinHeight() + 1, Math.min(world.getMaxHeight() - 1, at.getBlockY() + 2)) ? Math.max(world.getMinHeight() + 1, Math.min(world.getMaxHeight() - 1, at.getBlockY() + 2))
: world.getHighestBlockYAt(at.getBlockX(), at.getBlockZ()) + 2; : world.getHighestBlockYAt(at.getBlockX(), at.getBlockZ()) + 2;
Location destination = new Location(world, at.getBlockX() + 0.5, y, at.getBlockZ() + 0.5); Location destination = new Location(world, at.getBlockX() + 0.5, y, at.getBlockZ() + 0.5);
J.runEntity(target, () -> { J.runEntity(target, () -> completeStructureTeleport(target, world, commandSender, structure, destination));
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())));
});
} catch (Throwable t) { } catch (Throwable t) {
sendStructureMessage(target, commandSender, C.RED + "Could not prepare the destination for " + structure + "."); sendStructureMessage(target, commandSender, C.RED + "Could not prepare the destination for " + structure + ".");
Iris.reportError("Could not prepare structure destination '" + structure + "'.", t); 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) { private void sendStructureMessage(Player target, VolmitSender commandSender, String message) {
J.runEntity(target, () -> commandSender.sendMessage(message)); J.runEntity(target, () -> commandSender.sendMessage(message));
} }
@@ -21,6 +21,7 @@ package art.arcane.iris.command;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.configuration.IrisSettings; 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.ImageMapStudioGUI;
import art.arcane.iris.studio.view.NoiseExplorerGUI; import art.arcane.iris.studio.view.NoiseExplorerGUI;
import art.arcane.iris.studio.view.VisionGUI; import art.arcane.iris.studio.view.VisionGUI;
@@ -274,10 +275,6 @@ public class CommandStudio implements DirectorExecutor {
if (noGUI()) { if (noGUI()) {
return; return;
} }
if (!ImageMapStudioGUI.isAvailable()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS));
return;
}
if (!IrisToolbelt.isIrisWorld(world)) { if (!IrisToolbelt.isIrisWorld(world)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD));
return; 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)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS));
return true; return true;
} }
if (!GuiHost.isAvailable()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_DESKTOP_UNAVAILABLE));
return true;
}
return false; return false;
} }
@@ -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<Runnable> search = new AtomicReference<>();
private final CompletableFuture<Chunk> chunk = new CompletableFuture<>();
private final CompletableFuture<Boolean> teleport = new CompletableFuture<>();
private final MockedStatic<J> scheduler = mockStatic(J.class);
private final MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
private final MockedStatic<GenerationSemanticQueries> queries = mockStatic(GenerationSemanticQueries.class);
private final MockedStatic<IrisLanguage> language = mockStatic(IrisLanguage.class, invocation -> "teleported");
private final MockedStatic<Iris> 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.<Runnable>getArgument(1).run();
return true;
});
scheduler.when(() -> J.runRegion(any(World.class), anyInt(), anyInt(), any(Runnable.class))).thenAnswer(invocation -> {
invocation.<Runnable>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();
}
}
}
@@ -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<String> messages = new ArrayList<>();
private RecordingSender() {
super(null);
}
@Override
public void sendMessage(String message) {
messages.add(message);
}
}
}
@@ -60,10 +60,11 @@ final class EngineHotloader {
hotloadSilently(); hotloadSilently();
return; return;
} }
engine.awaitNativeStructureBootstrap("complex hotload");
synchronized (engine.lifecycleLock) { synchronized (engine.lifecycleLock) {
engine.requireRunning("rebuild the biome complex"); engine.requireRunning("rebuild the biome complex");
requireMutableGenerationRuntime("rebuild the biome complex"); requireMutableGenerationRuntime("rebuild the biome complex");
engine.awaitNativeStructureBootstrap("complex hotload"); engine.requireNativeStructureBootstrapComplete("complex hotload");
engine.lifecycleState = LifecycleState.HOTLOADING; engine.lifecycleState = LifecycleState.HOTLOADING;
EngineRuntime previous = engine.runtime; EngineRuntime previous = engine.runtime;
GenerationRuntime previousGeneration = previous.generation(); GenerationRuntime previousGeneration = previous.generation();
@@ -133,10 +134,11 @@ final class EngineHotloader {
hotloadStudio(router); hotloadStudio(router);
return; return;
} }
engine.awaitNativeStructureBootstrap("hotload");
synchronized (engine.lifecycleLock) { synchronized (engine.lifecycleLock) {
engine.requireRunning("hotload"); engine.requireRunning("hotload");
requireMutableGenerationRuntime("hotload"); requireMutableGenerationRuntime("hotload");
engine.awaitNativeStructureBootstrap("hotload"); engine.requireNativeStructureBootstrapComplete("hotload");
engine.lifecycleState = LifecycleState.HOTLOADING; engine.lifecycleState = LifecycleState.HOTLOADING;
EngineRuntime previousRuntime = engine.runtime; EngineRuntime previousRuntime = engine.runtime;
IrisDimension previousDimension = engine.getDimension(); IrisDimension previousDimension = engine.getDimension();
@@ -70,7 +70,10 @@ final class EngineShutdownSequence {
if (!engine.beginShutdown()) { if (!engine.beginShutdown()) {
return; return;
} }
Throwable routerFailure = stopWorldManager(); Throwable routerFailure = runCleanup(null, engine::drainNativeStructureBootstrap);
if (routerFailure == null) {
routerFailure = stopWorldManager();
}
if (routerFailure == null) { if (routerFailure == null) {
routerFailure = runCleanup(null, engine::closeAttachedGenerationHistoryRuntimeRouter); routerFailure = runCleanup(null, engine::closeAttachedGenerationHistoryRuntimeRouter);
} }
@@ -86,7 +89,6 @@ final class EngineShutdownSequence {
if (engine.closed) { if (engine.closed) {
return; return;
} }
engine.awaitNativeStructureBootstrap("close");
engine.lifecycleState = LifecycleState.CLOSING; engine.lifecycleState = LifecycleState.CLOSING;
engine.getClosing().set(true); engine.getClosing().set(true);
engine.backgroundTasks.closeBackgroundTaskAdmission(); engine.backgroundTasks.closeBackgroundTaskAdmission();
@@ -515,6 +515,21 @@ public class IrisEngine implements Engine {
nativeStructureBootstrapBarrier.await(transition); 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 @Override
public void generateMatter(int x, int z, boolean multicore, ChunkContext context) { public void generateMatter(int x, int z, boolean multicore, ChunkContext context) {
awaitGenerationCacheWarm(); awaitGenerationCacheWarm();
@@ -31,7 +31,7 @@ final class NativeStructureBootstrapBarrier {
starter.get(), starter.get(),
"Native structure bootstrap completion"); "Native structure bootstrap completion");
completion.whenComplete((ignored, failure) -> completeBridge(bridge, failure)); completion.whenComplete((ignored, failure) -> completeBridge(bridge, failure));
return bridge; return bridge.copy();
} catch (Throwable failure) { } catch (Throwable failure) {
poisoned = bridge; poisoned = bridge;
bridge.completeExceptionally(failure); bridge.completeExceptionally(failure);
@@ -45,32 +45,18 @@ final class NativeStructureBootstrapBarrier {
} }
} }
synchronized void await(String transition) { void await(String transition) {
CompletableFuture<Void> completion = active; Throwable failure = awaitCompletion(transition);
if (completion == null) { if (failure != null) {
return;
}
try {
completion.get(TRANSITION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
active = null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException( throw new IllegalStateException(
"Interrupted while waiting for native structure bootstrap before " + transition + ".", e); "Native structure bootstrap failed before " + transition + ".", failure);
} 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);
} }
} }
Throwable awaitForClose() {
return awaitCompletion("close");
}
synchronized boolean isActive() { synchronized boolean isActive() {
return active != null; return active != null;
} }
@@ -79,6 +65,45 @@ final class NativeStructureBootstrapBarrier {
return poisoned != null; return poisoned != null;
} }
private Throwable awaitCompletion(String transition) {
CompletableFuture<Void> 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<Void> completion) {
if (active == completion) {
active = null;
}
}
private void completeBridge(CompletableFuture<Void> bridge, Throwable failure) { private void completeBridge(CompletableFuture<Void> bridge, Throwable failure) {
if (failure == null) { if (failure == null) {
bridge.complete(null); bridge.complete(null);
@@ -699,6 +699,10 @@ public final class BukkitCommandMessagesExtended {
"iris.bukkit.commandstudio.you_must_have_server_launched_guis_enabled_settings", "iris.bukkit.commandstudio.you_must_have_server_launched_guis_enabled_settings",
C.RED + "You must have server launched GUIs enabled in the 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( public static final TextKey COMMAND_STUDIO_PLAYERS_ONLY = TextKey.of(
"iris.bukkit.commandstudio.players_only", "iris.bukkit.commandstudio.players_only",
C.RED + "Players only!" C.RED + "Players only!"
@@ -973,6 +977,7 @@ public final class BukkitCommandMessagesExtended {
COMMAND_STUDIO_CAPTURING_IGENDATA_FROM_NEARBY_CHUNKS, COMMAND_STUDIO_CAPTURING_IGENDATA_FROM_NEARBY_CHUNKS,
COMMAND_STUDIO_REPORTED, COMMAND_STUDIO_REPORTED,
COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS, COMMAND_STUDIO_YOU_MUST_HAVE_SERVER_LAUNCHED_GUIS_ENABLED_SETTINGS,
COMMAND_STUDIO_DESKTOP_UNAVAILABLE,
COMMAND_STUDIO_PLAYERS_ONLY, COMMAND_STUDIO_PLAYERS_ONLY,
COMMAND_STUDIO_NO_STUDIO_WORLD_IS_OPEN_2, COMMAND_STUDIO_NO_STUDIO_WORLD_IS_OPEN_2,
COMMAND_STUDIO_YOU_MUST_BE_STUDIO_WORLD, COMMAND_STUDIO_YOU_MUST_BE_STUDIO_WORLD,
@@ -2,10 +2,12 @@ package art.arcane.iris.studio.jigsaw;
import art.arcane.iris.studio.jigsaw.JigsawStudioService.ActiveStudio; import art.arcane.iris.studio.jigsaw.JigsawStudioService.ActiveStudio;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.event.ProtectionProbe;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.block.BlockState; import org.bukkit.block.BlockState;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; 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) { 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 if (event.getHand() != EquipmentSlot.HAND
|| (event.getAction() != Action.RIGHT_CLICK_AIR || (event.getAction() != Action.RIGHT_CLICK_AIR
&& event.getAction() != Action.RIGHT_CLICK_BLOCK)) { && event.getAction() != Action.RIGHT_CLICK_BLOCK)) {
@@ -299,6 +306,9 @@ final class JigsawStudioProtectionListener implements Listener {
return; return;
} }
event.setCancelled(true); event.setCancelled(true);
if (ProtectionProbe.isProbe(event)) {
return;
}
ActiveStudio studio = service.studios.get(event.getPlayer().getWorld().getUID()); ActiveStudio studio = service.studios.get(event.getPlayer().getWorld().getUID());
if (!authorizeOwner(event.getPlayer(), studio)) { if (!authorizeOwner(event.getPlayer(), studio)) {
return; return;
@@ -501,6 +511,9 @@ final class JigsawStudioProtectionListener implements Listener {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerInteract(PlayerInteractEvent event) { public void onPlayerInteract(PlayerInteractEvent event) {
if (ProtectionProbe.isProbe(event)) {
return;
}
if (event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.PHYSICAL) { if (event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.PHYSICAL) {
return; return;
} }
@@ -18,6 +18,7 @@
package art.arcane.iris.studio.object; package art.arcane.iris.studio.object;
import art.arcane.volmlib.util.event.ProtectionProbe;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisServices;
@@ -180,6 +181,9 @@ public class ObjectStudioSaveService implements IrisService {
@EventHandler(ignoreCancelled = true) @EventHandler(ignoreCancelled = true)
public void onPlayerInteract(PlayerInteractEvent event) { public void onPlayerInteract(PlayerInteractEvent event) {
if (ProtectionProbe.isProbe(event)) {
return;
}
if (event.getHand() != EquipmentSlot.HAND) return; if (event.getHand() != EquipmentSlot.HAND) return;
Action action = event.getAction(); Action action = event.getAction();
if (action != Action.RIGHT_CLICK_BLOCK && action != Action.LEFT_CLICK_BLOCK) return; if (action != Action.RIGHT_CLICK_BLOCK && action != Action.LEFT_CLICK_BLOCK) return;
@@ -47,16 +47,14 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Supplier; import java.util.function.Supplier;
public final class StudioOpenCoordinator { public final class StudioOpenCoordinator {
private static final long STUDIO_CLOSE_TIMEOUT_SECONDS = 120L; private static final long STUDIO_CLOSE_WARNING_SECONDS = 120L;
private static final long STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS = 30L;
private static volatile StudioOpenCoordinator instance; private static volatile StudioOpenCoordinator instance;
private StudioOpenCoordinator() { private StudioOpenCoordinator() {
@@ -136,6 +134,7 @@ public final class StudioOpenCoordinator {
private void executeOpen(StudioOpenRequest request, CompletableFuture<StudioOpenResult> future) { private void executeOpen(StudioOpenRequest request, CompletableFuture<StudioOpenResult> future) {
World world = null; World world = null;
PlatformChunkGenerator provider = null; PlatformChunkGenerator provider = null;
CompletableFuture<Void> entryBootstrap = null;
try { try {
long openStart = System.nanoTime(); long openStart = System.nanoTime();
long t = openStart; long t = openStart;
@@ -166,15 +165,14 @@ public final class StudioOpenCoordinator {
} }
World entryWorld = world; World entryWorld = world;
PlatformChunkGenerator entryProvider = provider; PlatformChunkGenerator entryProvider = provider;
CompletableFuture<Void> entryBootstrap = J.afut( entryBootstrap = endStudioEntryBootstrap(entryWorld, entryProvider);
() -> endStudioEntryBootstrap(entryWorld, entryProvider));
updateStage(request, "apply_world_rules", 0.72D); updateStage(request, "apply_world_rules", 0.72D);
final World rulesWorld = world; final World rulesWorld = world;
CompletableFuture<Boolean> rulesApplied = CompletableFuture<Boolean> rulesApplied =
J.sfut(() -> WorldRuntimeControlService.get().applyStudioWorldRules(rulesWorld)); J.sfut(() -> WorldRuntimeControlService.get().applyStudioWorldRules(rulesWorld));
if (rulesApplied != null) { if (rulesApplied != null) {
rulesApplied.get(15L, TimeUnit.SECONDS); rulesApplied.get();
} }
t = logStudioPhase(request, "apply_world_rules", t, openStart); t = logStudioPhase(request, "apply_world_rules", t, openStart);
@@ -191,7 +189,7 @@ public final class StudioOpenCoordinator {
t = logStudioPhase(request, "resolve_entry_anchor", t, openStart); t = logStudioPhase(request, "resolve_entry_anchor", t, openStart);
updateStage(request, "prepare_structure_rings", 0.79D); 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); t = logStudioPhase(request, "prepare_structure_rings", t, openStart);
updateStage(request, "prepare_generation_caches", 0.88D); updateStage(request, "prepare_generation_caches", 0.88D);
@@ -251,6 +249,9 @@ public final class StudioOpenCoordinator {
+ elapsedMillis(openStart) + "ms"); + elapsedMillis(openStart) + "ms");
future.complete(new StudioOpenResult(world, entryLocation)); future.complete(new StudioOpenResult(world, entryLocation));
} catch (Throwable e) { } catch (Throwable e) {
if (entryBootstrap != null) {
entryBootstrap.handle((ignored, failure) -> null).join();
}
abandonStudioEntryBootstrap(world, e); abandonStudioEntryBootstrap(world, e);
IrisLogging.reportError("Studio open failed for world \"" + request.worldName() + "\".", e); IrisLogging.reportError("Studio open failed for world \"" + request.worldName() + "\".", e);
if (!request.retainOnFailure()) { if (!request.retainOnFailure()) {
@@ -269,7 +270,7 @@ public final class StudioOpenCoordinator {
request.worldName(), request.worldName(),
world, world,
request.project()); request.project());
cleanup.get(45L, TimeUnit.SECONDS); cleanup.get();
} catch (Throwable cleanupError) { } catch (Throwable cleanupError) {
IrisLogging.reportError("Studio cleanup failed for world \"" IrisLogging.reportError("Studio cleanup failed for world \""
+ request.worldName() + "\".", unwrapFailure(cleanupError)); + request.worldName() + "\".", unwrapFailure(cleanupError));
@@ -317,7 +318,7 @@ public final class StudioOpenCoordinator {
} }
private void runOpenFinalizer(Consumer<World> finalizer, World world) private void runOpenFinalizer(Consumer<World> finalizer, World world)
throws InterruptedException, ExecutionException, TimeoutException { throws InterruptedException, ExecutionException {
if (finalizer == null) { if (finalizer == null) {
return; return;
} }
@@ -327,7 +328,7 @@ public final class StudioOpenCoordinator {
} }
CompletableFuture<Void> completion = J.sfut(() -> finalizer.accept(world)); CompletableFuture<Void> completion = J.sfut(() -> finalizer.accept(world));
completion.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); completion.get();
} }
private long elapsedMillis(long startedAtNanos) { private long elapsedMillis(long startedAtNanos) {
@@ -342,60 +343,23 @@ public final class StudioOpenCoordinator {
duration); duration);
} }
private void endStudioEntryBootstrap(World world, PlatformChunkGenerator provider) { private CompletableFuture<Void> endStudioEntryBootstrap(World world, PlatformChunkGenerator provider) {
if (!(provider instanceof BukkitChunkGenerator bukkitGenerator)) { 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<CompletableFuture<Void>> scheduledActivation = J.sfut(() -> { CompletableFuture<CompletableFuture<Void>> 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 { try {
CompletableFuture<Void> nativeActivation = return Objects.requireNonNull(INMS.get().completeStudioStructureBootstrap(world),
INMS.get().completeStudioStructureBootstrap(world); "Studio native structure activation completion");
if (nativeActivation == null) {
return CompletableFuture.failedFuture(new IllegalStateException(
"Studio native structure activation returned no completion future."));
}
return nativeActivation;
} catch (ReflectiveOperationException e) { } catch (ReflectiveOperationException e) {
return CompletableFuture.failedFuture(new IllegalStateException( return CompletableFuture.failedFuture(new IllegalStateException(
"Studio native structure state could not be activated after entry bootstrap.", e)); "Studio native structure state could not be activated after entry bootstrap.", e));
} }
}); });
CompletableFuture<Void> activation = scheduledActivation return scheduledActivation
.thenCompose(nativeActivation -> nativeActivation) .thenCompose(nativeActivation -> nativeActivation)
.thenCompose(ignored -> J.sfut(bukkitGenerator::endStudioEntryBootstrap)); .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) { private void abandonStudioEntryBootstrap(World world, Throwable failure) {
@@ -413,12 +377,12 @@ public final class StudioOpenCoordinator {
CompletableFuture<Void> abandonment = J.sfut( CompletableFuture<Void> abandonment = J.sfut(
() -> INMS.get().abandonStudioStructureBootstrap(world)); () -> INMS.get().abandonStudioStructureBootstrap(world));
try { try {
abandonment.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); abandonment.get();
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
failure.addSuppressed(new IllegalStateException( failure.addSuppressed(new IllegalStateException(
"Studio native structure abandonment was interrupted.", e)); "Studio native structure abandonment was interrupted.", e));
} catch (ExecutionException | TimeoutException e) { } catch (ExecutionException e) {
failure.addSuppressed(new IllegalStateException( failure.addSuppressed(new IllegalStateException(
"Studio native structure abandonment did not complete.", unwrapFailure(e))); "Studio native structure abandonment did not complete.", unwrapFailure(e)));
} }
@@ -475,7 +439,15 @@ public final class StudioOpenCoordinator {
failure 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<StudioCloseResult> closeWorldReserved( private CompletableFuture<StudioCloseResult> closeWorldReserved(
@@ -486,69 +458,37 @@ public final class StudioOpenCoordinator {
IrisProject project IrisProject project
) { ) {
AtomicBoolean unloadConfirmed = new AtomicBoolean(false); AtomicBoolean unloadConfirmed = new AtomicBoolean(false);
AtomicBoolean unloadStarted = new AtomicBoolean(false);
AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder); AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder);
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
if (world != null) { if (world != null) {
IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
} }
CompletableFuture<Void> sequence = sequenceStudioClose( CompletableFuture<Void> sequence = sequenceStudioClose(
() -> evacuateWorldFamily(worldName, world), () -> evacuateWorldFamily(worldName, world),
() -> { () -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)),
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);
}
});
},
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(), () -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
() -> deleteFolder () -> deleteFolder
? deleteWorldFamily(worldName).thenRun(() -> folderDeleted.set(true)) ? deleteWorldFamily(worldName).thenRun(() -> folderDeleted.set(true))
: CompletableFuture.completedFuture(null), : CompletableFuture.completedFuture(null)
terminalTimeout::get
); );
CompletableFuture<StudioCloseResult> operation = guardCloseCompletion( warnOnSlowClose(sequence, worldName,
sequence, CompletableFuture.delayedExecutor(STUDIO_CLOSE_WARNING_SECONDS, TimeUnit.SECONDS));
terminalTimeout, return sequence.thenApply(ignored -> {
worldName) if (project != null) {
.thenApply(ignored -> new StudioCloseResult( project.setActiveProvider(null);
worldName, project.setActiveOpenKind(null);
true, }
folderDeleted.get(), if (world != null) {
false, IrisToolbelt.endWorldMaintenance(world, "studio-close", true);
null }
)) return new StudioCloseResult(worldName, true, folderDeleted.get(), false, null);
})
.exceptionally(throwable -> { .exceptionally(throwable -> {
Throwable failure = unwrapFailure(throwable); Throwable failure = unwrapFailure(throwable);
requestRestartAfterPartialClose(worldName, unloadStarted.get());
boolean queued = deleteFolder && queueStartupCleanup(worldName, failure); boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
return new StudioCloseResult( return new StudioCloseResult(
worldName, worldName, unloadConfirmed.get(), folderDeleted.get(), queued, failure);
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<Void> sequenceStudioClose( static CompletableFuture<Void> sequenceStudioClose(
@@ -556,75 +496,28 @@ public final class StudioOpenCoordinator {
Supplier<CompletableFuture<Void>> unload, Supplier<CompletableFuture<Void>> unload,
Supplier<CompletableFuture<Void>> closeGenerator, Supplier<CompletableFuture<Void>> closeGenerator,
Supplier<CompletableFuture<Void>> deleteFolders Supplier<CompletableFuture<Void>> deleteFolders
) {
return sequenceStudioClose(evacuate, unload, closeGenerator, deleteFolders, () -> false);
}
static CompletableFuture<Void> sequenceStudioClose(
Supplier<CompletableFuture<Void>> evacuate,
Supplier<CompletableFuture<Void>> unload,
Supplier<CompletableFuture<Void>> closeGenerator,
Supplier<CompletableFuture<Void>> deleteFolders,
BooleanSupplier terminalTimeout
) { ) {
return invokePhase(evacuate) return invokePhase(evacuate)
.thenCompose(ignored -> invokePhaseUnlessTimedOut(unload, terminalTimeout)) .thenCompose(ignored -> invokePhase(unload))
.thenCompose(ignored -> invokePhaseUnlessTimedOut(closeGenerator, terminalTimeout)) .thenCompose(ignored -> invokePhase(closeGenerator))
.thenCompose(ignored -> invokePhaseUnlessTimedOut(deleteFolders, terminalTimeout)); .thenCompose(ignored -> invokePhase(deleteFolders));
} }
private static CompletableFuture<Void> invokePhase(Supplier<CompletableFuture<Void>> phase) { private static CompletableFuture<Void> invokePhase(Supplier<CompletableFuture<Void>> phase) {
try { try {
CompletableFuture<Void> future = phase.get(); return Objects.requireNonNull(phase.get(), "Studio close phase completion");
if (future == null) {
return CompletableFuture.failedFuture(new IllegalStateException("Studio close phase returned no completion future."));
}
return future;
} catch (Throwable failure) { } catch (Throwable failure) {
return CompletableFuture.failedFuture(failure); return CompletableFuture.failedFuture(failure);
} }
} }
private static CompletableFuture<Void> invokePhaseUnlessTimedOut( static void warnOnSlowClose(CompletableFuture<Void> completion, String worldName, Executor warningExecutor) {
Supplier<CompletableFuture<Void>> phase, warningExecutor.execute(() -> {
BooleanSupplier terminalTimeout if (!completion.isDone()) {
) { IrisLogging.warn("Studio cleanup is still running for \"" + worldName
if (terminalTimeout.getAsBoolean()) { + "\". Iris is waiting for active work before releasing world resources.");
return CompletableFuture.failedFuture(new TimeoutException(
"Studio close stopped after its terminal timeout."));
}
return invokePhase(phase);
}
private CompletableFuture<Void> guardCloseCompletion(
CompletableFuture<Void> source,
AtomicBoolean terminalTimeout,
String worldName
) {
CompletableFuture<Void> 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);
} }
}); });
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<Void> evacuateWorldFamily(String worldName, World primaryWorld) { private CompletableFuture<Void> evacuateWorldFamily(String worldName, World primaryWorld) {
@@ -653,7 +546,7 @@ public final class StudioOpenCoordinator {
CompletableFuture<Boolean> unload = J.sfut(() -> CompletableFuture<Boolean> unload = J.sfut(() ->
IrisServices.get(MultiverseCoreLink.class) IrisServices.get(MultiverseCoreLink.class)
.removeIfPresent(loadedWorld)) .removeIfPresent(loadedWorld))
.thenCompose(ignored -> WorldLifecycleService.get().unloadAsync(loadedWorld, false)); .thenCompose(ignored -> WorldLifecycleService.get().unloadSettledAsync(loadedWorld, false));
unloads.add(unload); unloads.add(unload);
} }
@@ -743,7 +636,7 @@ public final class StudioOpenCoordinator {
null, null,
true, true,
null null
).get(30L, TimeUnit.SECONDS); ).get();
if (cleanupResult.failureCause() != null) { if (cleanupResult.failureCause() != null) {
IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", cleanupResult.failureCause()); IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", cleanupResult.failureCause());
} }
@@ -159,7 +159,14 @@ public final class ImageMapStudioGUI {
} }
public static void launch(Engine engine) { 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) { static void reloadActiveEngine(Engine engine) {
@@ -216,17 +216,29 @@ public final class NoiseExplorerGUI extends JPanel {
} }
public static void launch() { public static void launch() {
if (!GuiHost.isAvailable()) {
return;
}
Engine engine = GuiHost.get().findActiveEngine(); Engine engine = GuiHost.get().findActiveEngine();
EventQueue.invokeLater(() -> { EventQueue.invokeLater(() -> {
if (!GuiHost.isAvailable()) {
return;
}
NoiseExplorerGUI explorer = new NoiseExplorerGUI(engine, null, null, null, DEFAULT_SEED, null, null); NoiseExplorerGUI explorer = new NoiseExplorerGUI(engine, null, null, null, DEFAULT_SEED, null, null);
buildFrame(IrisLanguage.plain(DesktopUiMessages.NOISE_TITLE), explorer); buildFrame(IrisLanguage.plain(DesktopUiMessages.NOISE_TITLE), explorer);
}); });
} }
public static void launchGeneratorKey(String generatorKey, IrisGenerator fallbackGenerator, long initialSeed) { public static void launchGeneratorKey(String generatorKey, IrisGenerator fallbackGenerator, long initialSeed) {
if (!GuiHost.isAvailable()) {
return;
}
Engine engine = GuiHost.get().findActiveEngine(); Engine engine = GuiHost.get().findActiveEngine();
String displayName = generatorKey == null || generatorKey.isBlank() ? "Custom Generator" : generatorKey; String displayName = generatorKey == null || generatorKey.isBlank() ? "Custom Generator" : generatorKey;
EventQueue.invokeLater(() -> { EventQueue.invokeLater(() -> {
if (!GuiHost.isAvailable()) {
return;
}
NoiseExplorerGUI explorer = new NoiseExplorerGUI( NoiseExplorerGUI explorer = new NoiseExplorerGUI(
engine, engine,
generatorKey, generatorKey,
@@ -209,10 +209,16 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
} }
public static void launch(Engine engine, UUID openerId) { public static void launch(Engine engine, UUID openerId) {
if (!GuiHost.isAvailable()) {
return;
}
EventQueue.invokeLater(() -> createAndShowGUI(engine, openerId)); EventQueue.invokeLater(() -> createAndShowGUI(engine, openerId));
} }
private static void createAndShowGUI(Engine engine, UUID openerId) { private static void createAndShowGUI(Engine engine, UUID openerId) {
if (!GuiHost.isAvailable()) {
return;
}
JFrame frame = new JFrame(IrisLanguage.plain(DesktopUiMessages.VISION_TITLE)); JFrame frame = new JFrame(IrisLanguage.plain(DesktopUiMessages.VISION_TITLE));
GuiHost.prepareFrame(frame); GuiHost.prepareFrame(frame);
VisionGUI vision = new VisionGUI(frame, engine, openerId); VisionGUI vision = new VisionGUI(frame, engine, openerId);
@@ -123,6 +123,11 @@ public final class WorldLifecycleService {
} }
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) { public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
World requiredWorld = Objects.requireNonNull(world, "world");
return guardUnloadCompletion(requiredWorld.getName(), unloadSettledAsync(requiredWorld, save));
}
public CompletableFuture<Boolean> unloadSettledAsync(World world, boolean save) {
World requiredWorld = Objects.requireNonNull(world, "world"); World requiredWorld = Objects.requireNonNull(world, "world");
String worldIdentity = WorldIdentity.serialize(requiredWorld); String worldIdentity = WorldIdentity.serialize(requiredWorld);
String worldName = requiredWorld.getName(); String worldName = requiredWorld.getName();
@@ -150,8 +155,7 @@ public final class WorldLifecycleService {
unloadFuture.whenComplete((unloaded, throwable) -> unloadFuture.whenComplete((unloaded, throwable) ->
WorldUnloadBoundaryRegistry.complete(rawBoundary, unloaded, throwable)); WorldUnloadBoundaryRegistry.complete(rawBoundary, unloaded, throwable));
CompletableFuture<Boolean> guardedFuture = guardUnloadCompletion(worldName, unloadFuture); return unloadFuture.whenComplete((unloaded, throwable) -> {
return guardedFuture.whenComplete((unloaded, throwable) -> {
if (throwable != null) { if (throwable != null) {
Throwable cause = WorldLifecycleSupport.unwrap(throwable); Throwable cause = WorldLifecycleSupport.unwrap(throwable);
IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + worldName IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + worldName
@@ -162,7 +166,7 @@ public final class WorldLifecycleService {
if (Boolean.TRUE.equals(unloaded)) { if (Boolean.TRUE.equals(unloaded)) {
worldBackendByKey.remove(worldIdentity, backend.backendName()); worldBackendByKey.remove(worldIdentity, backend.backendName());
} }
}); }).copy();
} }
private CompletableFuture<Boolean> guardUnloadCompletion( private CompletableFuture<Boolean> guardUnloadCompletion(
@@ -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." capturing_igendata_from_nearby_chunks = "IGenData wird aus {value} nahe gelegenen Chunks erfasst."
reported = "Gemeldet an: {value}" reported = "Gemeldet an: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cIn den Einstellungen müssen vom Server gestartete GUIs aktiviert sein!" 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!" players_only = "§cNur Spieler!"
no_studio_world_is_open_2 = "§cKeine Studio-Welt ist offen!" no_studio_world_is_open_2 = "§cKeine Studio-Welt ist offen!"
you_must_be_studio_world = "§cDu musst in einer Studiowelt sein!" you_must_be_studio_world = "§cDu musst in einer Studiowelt sein!"
@@ -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." capturing_igendata_from_nearby_chunks = "Capturando IGenData de {value} chunks cercanos."
reported = "Informe escrito en: {value}" 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!" 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!" players_only = "§c¡Solo jugadores!"
no_studio_world_is_open_2 = "§c¡No hay ningún mundo de Studio abierto!" 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!" you_must_be_studio_world = "§c¡Debes estar en un mundo de Studio!"
@@ -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." capturing_igendata_from_nearby_chunks = "Kaappaus IGenData alkaen {value} Lähellä on chunkia."
reported = "Raportoitu: {value}" reported = "Raportoitu: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cSinulla täytyy olla palvelin käynnistetty GUIs käytössä asetuksissa!" 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!" players_only = "§cVain pelaajille!"
no_studio_world_is_open_2 = "§cYksikään studiomaailma ei ole auki!" no_studio_world_is_open_2 = "§cYksikään studiomaailma ei ole auki!"
you_must_be_studio_world = "§cOlet varmaan studiomaailmassa!" you_must_be_studio_world = "§cOlet varmaan studiomaailmassa!"
@@ -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é." capturing_igendata_from_nearby_chunks = "Capture d'IGenData depuis {value} chunks à proximité."
reported = "Rapport écrit dans : {value}" 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 !" 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 !" players_only = "§cJoueurs uniquement !"
no_studio_world_is_open_2 = "§cAucun monde Studio n'est ouvert !" no_studio_world_is_open_2 = "§cAucun monde Studio n'est ouvert !"
you_must_be_studio_world = "§cVous devez être dans un monde Studio !" you_must_be_studio_world = "§cVous devez être dans un monde Studio !"
@@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "אתה חייב להיות בעולם חג המול
capturing_igendata_from_nearby_chunks = "איסוף הנתונים של {value} הסמוך." capturing_igendata_from_nearby_chunks = "איסוף הנתונים של {value} הסמוך."
reported = "דווח על: {value}" reported = "דווח על: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cאתה חייב לפתוח את השרת GUIs אפשרו בהגדרות!" you_must_have_server_launched_guis_enabled_settings = "§cאתה חייב לפתוח את השרת GUIs אפשרו בהגדרות!"
desktop_unavailable = "§cאין לשרת הזה תצוגת שולחן עבודה זמינה. חלונות Studio נפתחים בשרת. יש להפעיל את Iris בסביבת שולחן עבודה גרפית."
players_only = "§cשחקנים בלבד!" players_only = "§cשחקנים בלבד!"
no_studio_world_is_open_2 = "§cאין עולם סטודיו פתוח!" no_studio_world_is_open_2 = "§cאין עולם סטודיו פתוח!"
you_must_be_studio_world = "§cאתה חייב להיות בעולם סטודיו!" you_must_be_studio_world = "§cאתה חייב להיות בעולם סטודיו!"
@@ -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." capturing_igendata_from_nearby_chunks = "Acquisizione di IGenData da {value} chunk vicini."
reported = "Segnalato a: {value}" reported = "Segnalato a: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cDevi abilitare nelle impostazioni le GUI avviate dal server!" 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!" players_only = "§cSolo i giocatori!"
no_studio_world_is_open_2 = "§cNessun mondo di studio è aperto!" no_studio_world_is_open_2 = "§cNessun mondo di studio è aperto!"
you_must_be_studio_world = "§cDevi essere in uno studio!" you_must_be_studio_world = "§cDevi essere in uno studio!"
@@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "Iris ワールド内にいる必要があります
capturing_igendata_from_nearby_chunks = "周辺の {value} チャンクから IGenData をキャプチャしています。" capturing_igendata_from_nearby_chunks = "周辺の {value} チャンクから IGenData をキャプチャしています。"
reported = "報告: {value}" reported = "報告: {value}"
you_must_have_server_launched_guis_enabled_settings = "§c設定でサーバー起動 GUI を有効にする必要があります!" you_must_have_server_launched_guis_enabled_settings = "§c設定でサーバー起動 GUI を有効にする必要があります!"
desktop_unavailable = "§cこのサーバーには利用可能なデスクトップ画面がありません。Studio のウィンドウはサーバー上で開くため、グラフィカルなデスクトップセッションで Iris を実行してください。"
players_only = "§cプレイヤーのみ!" players_only = "§cプレイヤーのみ!"
no_studio_world_is_open_2 = "§c開いているスタジオワールドはありません!" no_studio_world_is_open_2 = "§c開いているスタジオワールドはありません!"
you_must_be_studio_world = "§cスタジオワールドでなければいけません!" you_must_be_studio_world = "§cスタジオワールドでなければいけません!"
@@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "당신은 아이리스 세계에 있어야합니다.
capturing_igendata_from_nearby_chunks = "주변 {value}개 청크에서 IGenData를 캡처하는 중입니다." capturing_igendata_from_nearby_chunks = "주변 {value}개 청크에서 IGenData를 캡처하는 중입니다."
reported = "보고 위치: {value}" reported = "보고 위치: {value}"
you_must_have_server_launched_guis_enabled_settings = "§c서버가 시작되어야 합니다. GUIs 설정에서 활성화!" you_must_have_server_launched_guis_enabled_settings = "§c서버가 시작되어야 합니다. GUIs 설정에서 활성화!"
desktop_unavailable = "§c이 서버에는 사용 가능한 데스크톱 화면이 없습니다. Studio 창은 서버에서 열리므로 그래픽 데스크톱 세션에서 Iris를 실행하세요."
players_only = "§c플레이어 만!" players_only = "§c플레이어 만!"
no_studio_world_is_open_2 = "§c스튜디오 세계가 오픈!" no_studio_world_is_open_2 = "§c스튜디오 세계가 오픈!"
you_must_be_studio_world = "§c스튜디오 세계에 있어야합니다!" you_must_be_studio_world = "§c스튜디오 세계에 있어야합니다!"
@@ -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." capturing_igendata_from_nearby_chunks = "Gaudymas IGenData nuo {value} Šalia šakutės."
reported = "Pranešama apie: {value}" reported = "Pranešama apie: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cJūs turite paleisti serverį GUIs įjungta nustatymuose!" 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!" players_only = "§cTik žaidėjai!"
no_studio_world_is_open_2 = "§cJokio studijos pasaulio nėra!" no_studio_world_is_open_2 = "§cJokio studijos pasaulio nėra!"
you_must_be_studio_world = "§cJūs turbūt studijuojat!" you_must_be_studio_world = "§cJūs turbūt studijuojat!"
@@ -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." capturing_igendata_from_nearby_chunks = "Vangen IGenData van {value} In de buurt."
reported = "Gerapporteerd aan: {value}" reported = "Gerapporteerd aan: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cU moet de server gestart hebben GUIs ingeschakeld in de instellingen!" 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!" players_only = "§cAlleen spelers!"
no_studio_world_is_open_2 = "§cGeen enkele studiowereld is open!" no_studio_world_is_open_2 = "§cGeen enkele studiowereld is open!"
you_must_be_studio_world = "§cJe moet in een studio wereld zijn!" you_must_be_studio_world = "§cJe moet in een studio wereld zijn!"
@@ -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." capturing_igendata_from_nearby_chunks = "Chwytanie IGenData od {value} Pobliskie chunki."
reported = "Zgłoszone do: {value}" reported = "Zgłoszone do: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cMusisz mieć serwer uruchomiony GUIs włączone w ustawieniach!" 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!" players_only = "§cTylko gracze!"
no_studio_world_is_open_2 = "§cŻadne studio nie jest otwarte!" no_studio_world_is_open_2 = "§cŻadne studio nie jest otwarte!"
you_must_be_studio_world = "§cMusisz być w świecie studia!" you_must_be_studio_world = "§cMusisz być w świecie studia!"
@@ -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." capturing_igendata_from_nearby_chunks = "Captura IGenData de {value} Pedaços próximos."
reported = "Comunicado em: {value}" reported = "Comunicado em: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cVocê deve ter lançado o servidor GUIs habilitado nas configurações!" 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!" players_only = "§cSó jogadores!"
no_studio_world_is_open_2 = "§cNenhum mundo de estúdio está aberto!" 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!" you_must_be_studio_world = "§cDeves estar num mundo de estúdio!"
@@ -669,6 +669,7 @@ you_must_be_iris_world_2 = "Вы должны быть в мире радужн
capturing_igendata_from_nearby_chunks = "захватывать IGenData из {value} близлежащих кусков." capturing_igendata_from_nearby_chunks = "захватывать IGenData из {value} близлежащих кусков."
reported = "Сообщалось: {value}" reported = "Сообщалось: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cВы должны запустить сервер GUIs Включено в настройках!" you_must_have_server_launched_guis_enabled_settings = "§cВы должны запустить сервер GUIs Включено в настройках!"
desktop_unavailable = "§cНа этом сервере недоступен графический рабочий стол. Окна Studio открываются на сервере. Запустите Iris в графическом сеансе рабочего стола."
players_only = "§cТолько игроки!" players_only = "§cТолько игроки!"
no_studio_world_is_open_2 = "§cНи один студийный мир не открыт!" no_studio_world_is_open_2 = "§cНи один студийный мир не открыт!"
you_must_be_studio_world = "§cВы должны быть в студии!" you_must_be_studio_world = "§cВы должны быть в студии!"
@@ -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." capturing_igendata_from_nearby_chunks = "Sheturing IGenData -den {value} Yakındaki chunks."
reported = "Rapor: {value}" reported = "Rapor: {value}"
you_must_have_server_launched_guis_enabled_settings = "§cSunucu başlatmış olmalısın GUIs ayarlarda etkinleştirin!" 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!" players_only = "§cOyuncular sadece!"
no_studio_world_is_open_2 = "§cStüdyo dünyası açık değil!" 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!" you_must_be_studio_world = "§cBir stüdyo dünyasında olmalısın!"
@@ -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." capturing_igendata_from_nearby_chunks = "Bắt buộc IGenData từ {value} Gần đây thôi."
reported = "Báo cáo: {value}" 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." 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!" 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ả!" 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!" you_must_be_studio_world = "§cCậu chắc đang ở trong phòng thu!"
@@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "你一定在Iris世界里"
capturing_igendata_from_nearby_chunks = "隐藏 IGenData 从 {value} 附近块。" capturing_igendata_from_nearby_chunks = "隐藏 IGenData 从 {value} 附近块。"
reported = "向: {value}" reported = "向: {value}"
you_must_have_server_launched_guis_enabled_settings = "§c您必须已启动服务器 GUIs 在设置中启用 !" you_must_have_server_launched_guis_enabled_settings = "§c您必须已启动服务器 GUIs 在设置中启用 !"
desktop_unavailable = "§c此服务器没有可用的桌面显示环境。Studio 窗口会在服务器上打开,请在图形桌面会话中运行 Iris。"
players_only = "§c只有球员!" players_only = "§c只有球员!"
no_studio_world_is_open_2 = "§c没有工作室的世界是开放的!" no_studio_world_is_open_2 = "§c没有工作室的世界是开放的!"
you_must_be_studio_world = "§c你一定在一个工作室的世界!" you_must_be_studio_world = "§c你一定在一个工作室的世界!"
@@ -667,6 +667,7 @@ you_must_be_iris_world_2 = "你一定在Iris世界裡"
capturing_igendata_from_nearby_chunks = "隱藏 IGenData 從 {value} 附近塊。" capturing_igendata_from_nearby_chunks = "隱藏 IGenData 從 {value} 附近塊。"
reported = "向: {value}" reported = "向: {value}"
you_must_have_server_launched_guis_enabled_settings = "§c您必須已啟動伺服器 GUIs 在設定中啟用 !" you_must_have_server_launched_guis_enabled_settings = "§c您必須已啟動伺服器 GUIs 在設定中啟用 !"
desktop_unavailable = "§c此伺服器沒有可用的桌面顯示環境。Studio 視窗會在伺服器上開啟,請在圖形桌面工作階段中執行 Iris。"
players_only = "§c只有球員!" players_only = "§c只有球員!"
no_studio_world_is_open_2 = "§c沒有工作室的世界是開放的!" no_studio_world_is_open_2 = "§c沒有工作室的世界是開放的!"
you_must_be_studio_world = "§c你一定在一個工作室的世界!" you_must_be_studio_world = "§c你一定在一個工作室的世界!"
@@ -97,6 +97,59 @@ public class EngineShutdownDrainTest {
} }
} }
@Test
public void settledStructureBootstrapFailureDoesNotPreventEngineCleanup() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
setField(fixture.engine, "nativeStructureBootstrapBarrier", barrier);
CompletableFuture<Void> rings = new CompletableFuture<>();
barrier.start(() -> {
}, () -> rings);
rings.completeExceptionally(new IllegalStateException("Ring worker failed"));
doCallRealMethod().when(fixture.engine).drainNativeStructureBootstrap();
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
fixture.shutdown.close();
}
verify(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter();
verify(fixture.mantle).close();
assertTrue(fixture.engine.closed);
}
@Test
public void untrackedStructureBootstrapKeepsItsRuntimeAndRouterOpen() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
doThrow(new IllegalStateException("Bootstrap could not be tracked"))
.when(fixture.engine).drainNativeStructureBootstrap();
assertThrows(IllegalStateException.class, fixture.shutdown::close);
verify(fixture.engine, never()).closeAttachedGenerationHistoryRuntimeRouter();
verify(fixture.mantle, never()).close();
assertFalse(fixture.engine.closed);
}
@Test
public void structureBootstrapDrainsBeforeItsHistoryRouterCloses() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
AtomicBoolean ringsRunning = new AtomicBoolean(true);
doAnswer(invocation -> {
assertFalse(Thread.holdsLock(fixture.engine.lifecycleLock));
ringsRunning.set(false);
return null;
}).when(fixture.engine).drainNativeStructureBootstrap();
doAnswer(invocation -> {
assertFalse("Ring workers still need their generation-history router", ringsRunning.get());
return null;
}).when(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter();
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
fixture.shutdown.close();
}
assertTrue(fixture.engine.closed);
}
@Test @Test
public void queuedWorldManagerRoutesAreCancelledBeforeRouterDrain() throws Exception { public void queuedWorldManagerRoutesAreCancelledBeforeRouterDrain() throws Exception {
ShutdownFixture fixture = new ShutdownFixture(); ShutdownFixture fixture = new ShutdownFixture();
@@ -541,6 +594,11 @@ public class EngineShutdownDrainTest {
ShutdownFixture fixture = new ShutdownFixture(); ShutdownFixture fixture = new ShutdownFixture();
IrisComplex replacement = mock(IrisComplex.class); IrisComplex replacement = mock(IrisComplex.class);
fixture.prepareComplexHotload(replacement); 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(); new EngineHotloader(fixture.engine).hotloadComplex();
@@ -880,6 +880,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
setField(engine, "generationRuntimeRetirementListeners", new CopyOnWriteArraySet<IntConsumer>()); setField(engine, "generationRuntimeRetirementListeners", new CopyOnWriteArraySet<IntConsumer>());
setField(engine, "shutdownSequence", new EngineShutdownSequence(engine)); setField(engine, "shutdownSequence", new EngineShutdownSequence(engine));
setField(engine, "hotloader", new EngineHotloader(engine)); setField(engine, "hotloader", new EngineHotloader(engine));
setField(engine, "nativeStructureBootstrapBarrier", new NativeStructureBootstrapBarrier());
setField(engine, "nativeStructureVolumeMemo", new NativeStructureVolumeMemo()); setField(engine, "nativeStructureVolumeMemo", new NativeStructureVolumeMemo());
setField(engine, "closing", new AtomicBoolean(false)); setField(engine, "closing", new AtomicBoolean(false));
engine.lifecycleState = IrisEngine.LifecycleState.RUNNING; engine.lifecycleState = IrisEngine.LifecycleState.RUNNING;
@@ -10,10 +10,72 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class NativeStructureBootstrapBarrierTest { public class NativeStructureBootstrapBarrierTest {
@Test
public void failedRingWorkersCanBeReleasedOnceTheirCompletionSettles() {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
CompletableFuture<Void> rings = new CompletableFuture<>();
IllegalStateException failure = new IllegalStateException("ring failure");
barrier.start(() -> {
}, () -> rings);
rings.completeExceptionally(failure);
assertSame(failure, barrier.awaitForClose());
assertFalse(barrier.isActive());
}
@Test
public void untrackedStarterFailureDoesNotAllowResourceRelease() {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
assertThrows(IllegalStateException.class, () -> barrier.start(() -> {
}, () -> {
throw new IllegalStateException("partial scheduling");
}));
assertThrows(IllegalStateException.class, barrier::awaitForClose);
assertTrue(barrier.isActive());
}
@Test
public void exposedCompletionCannotReleaseRunningRingWork() {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
CompletableFuture<Void> rings = new CompletableFuture<>();
CompletableFuture<Void> completion = barrier.start(() -> {
}, () -> rings);
completion.complete(null);
assertTrue(barrier.isActive());
rings.complete(null);
assertFalse(barrier.isActive());
}
@Test
public void waitingDoesNotHoldTheBarrierMonitor() throws Exception {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
CompletableFuture<Void> rings = new CompletableFuture<>();
barrier.start(() -> {
}, () -> rings);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<?> close = executor.submit(() -> barrier.await("close"));
assertThrows(TimeoutException.class, () -> close.get(50L, TimeUnit.MILLISECONDS));
Future<?> worker = executor.submit(() -> {
assertTrue(barrier.isActive());
rings.complete(null);
});
worker.get(1L, TimeUnit.SECONDS);
close.get(1L, TimeUnit.SECONDS);
} finally {
rings.complete(null);
executor.shutdownNow();
}
}
@Test @Test
public void immediateCloseWaitsForExactRingCompletion() throws Exception { public void immediateCloseWaitsForExactRingCompletion() throws Exception {
NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier(); NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier();
@@ -0,0 +1,74 @@
package art.arcane.iris.studio.jigsaw;
import art.arcane.volmlib.util.event.ProtectionProbe;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.PlayerInventory;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.UUID;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class JigsawStudioProtectionProbeTest {
@Test
public void previewDoesNotMarkStudioDirty() throws Exception {
JigsawStudioService service = new JigsawStudioService();
JigsawStudioDirtyTracker tracker = mock(JigsawStudioDirtyTracker.class);
JigsawStudioTileWatcher watcher = mock(JigsawStudioTileWatcher.class);
setField(service, "dirtyTracker", tracker);
setField(service, "tileWatcher", watcher);
PlayerInteractEvent event = probe();
service.protectionListener.onPlayerInteract(event);
verify(tracker, never()).markDirty(event.getClickedBlock());
verify(watcher, never()).finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
}
@Test
public void controlChestProbeRemainsDeniedWithoutOpeningItsMenu() throws Exception {
JigsawStudioService service = new JigsawStudioService();
JigsawStudioProtection protection = mock(JigsawStudioProtection.class);
JigsawStudioToolbelt toolbelt = mock(JigsawStudioToolbelt.class);
setField(service, "protection", protection);
setField(service, "toolbelt", toolbelt);
PlayerInteractEvent event = probe();
when(protection.isControlChest(event.getClickedBlock())).thenReturn(true);
try (MockedStatic<JigsawStudioProtection> authorization = mockStatic(JigsawStudioProtection.class)) {
authorization.when(() -> JigsawStudioProtection.authorizeOwner(event.getPlayer(), null)).thenReturn(true);
service.protectionListener.onControlChest(event);
assertTrue(event.isCancelled());
verify(toolbelt, never()).openControlMenu(any(Player.class));
}
}
private static PlayerInteractEvent probe() {
Player player = mock(Player.class);
World world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(player.getWorld()).thenReturn(world);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.getInventory()).thenReturn(mock(PlayerInventory.class));
return ProtectionProbe.blockInteract(player, mock(Block.class), EquipmentSlot.HAND);
}
private static void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}
@@ -0,0 +1,92 @@
package art.arcane.iris.studio.jigsaw;
import java.lang.reflect.Field;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredListener;
import org.junit.Test;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class JigsawStudioToolInteractionTest {
@Test
public void airClickReachesTheToolDespiteBukkitsDefaultCancellation() throws Exception {
Fixture fixture = fixture();
PlayerInteractEvent event = event(fixture.player(), Action.RIGHT_CLICK_AIR, null);
assertTrue(event.isCancelled());
dispatch(fixture.listener(), event);
verify(fixture.toolbelt()).useTool(fixture.player(), fixture.payload(), null, false);
verify(fixture.watcher()).finalizeJigsawTileWatchesForPlayer(fixture.player().getUniqueId());
}
@Test
public void deniedBlockAndItemInteractionsDoNotUseTheTool() throws Exception {
Fixture fixture = fixture();
PlayerInteractEvent block = event(fixture.player(), Action.RIGHT_CLICK_BLOCK, mock(Block.class));
block.setUseInteractedBlock(Event.Result.DENY);
dispatch(fixture.listener(), block);
PlayerInteractEvent air = event(fixture.player(), Action.RIGHT_CLICK_AIR, null);
air.setUseItemInHand(Event.Result.DENY);
dispatch(fixture.listener(), air);
PlayerInteractEvent item = event(fixture.player(), Action.RIGHT_CLICK_BLOCK, mock(Block.class));
item.setUseItemInHand(Event.Result.DENY);
dispatch(fixture.listener(), item);
verifyNoInteractions(fixture.codec(), fixture.toolbelt(), fixture.watcher());
}
private static Fixture fixture() throws Exception {
JigsawStudioService service = new JigsawStudioService();
JigsawStudioToolbelt toolbelt = mock(JigsawStudioToolbelt.class);
JigsawStudioToolCodec codec = mock(JigsawStudioToolCodec.class);
JigsawStudioTileWatcher watcher = mock(JigsawStudioTileWatcher.class);
JigsawStudioToolPayload payload = mock(JigsawStudioToolPayload.class);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(codec.decode((ItemStack) null)).thenReturn(Optional.of(payload));
setField(service, "toolbelt", toolbelt);
setField(service, "tileWatcher", watcher);
setField(toolbelt, "toolCodec", codec);
return new Fixture(service.protectionListener, toolbelt, codec, watcher, payload, player);
}
private static PlayerInteractEvent event(Player player, Action action, Block block) {
return new PlayerInteractEvent(player, action, null, block, BlockFace.UP, EquipmentSlot.HAND);
}
private static void dispatch(JigsawStudioProtectionListener listener, PlayerInteractEvent event) throws Exception {
EventHandler annotation = JigsawStudioProtectionListener.class
.getMethod("onUseTool", PlayerInteractEvent.class).getAnnotation(EventHandler.class);
RegisteredListener registered = new RegisteredListener(listener,
(ignored, dispatched) -> listener.onUseTool((PlayerInteractEvent) dispatched),
EventPriority.LOWEST, mock(Plugin.class), annotation.ignoreCancelled());
registered.callEvent(event);
}
private static void setField(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
private record Fixture(JigsawStudioProtectionListener listener, JigsawStudioToolbelt toolbelt,
JigsawStudioToolCodec codec, JigsawStudioTileWatcher watcher,
JigsawStudioToolPayload payload, Player player) {
}
}
@@ -0,0 +1,82 @@
package art.arcane.iris.studio.object;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.iris.platform.bukkit.nms.INMSBinding;
import art.arcane.iris.platform.generation.BukkitChunkGenerator;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.world.task.J;
import org.bukkit.World;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Supplier;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class StudioBootstrapActivationTest {
@Test(timeout = 5000L)
public void activationReturnsPendingFutureAndFinishesOnlyAfterNativeRings() throws Exception {
verifyActivation(false);
}
@Test(timeout = 5000L)
public void failedNativeRingsPreserveCauseAndDoNotEndBootstrap() throws Exception {
verifyActivation(true);
}
private void verifyActivation(boolean fail) throws Exception {
BukkitChunkGenerator provider = mock(BukkitChunkGenerator.class);
World world = mock(World.class);
INMSBinding nms = mock(INMSBinding.class);
CompletableFuture<Void> rings = new CompletableFuture<>();
when(nms.completeStudioStructureBootstrap(world)).thenReturn(rings);
try (MockedStatic<INMS> binding = mockStatic(INMS.class);
MockedStatic<J> scheduling = mockStatic(J.class)) {
binding.when(INMS::get).thenReturn(nms);
scheduling.when(() -> J.sfut(any(Supplier.class))).thenAnswer(invocation -> {
Supplier<?> supplier = invocation.getArgument(0);
return CompletableFuture.completedFuture(supplier.get());
});
scheduling.when(() -> J.sfut(any(Runnable.class))).thenAnswer(invocation -> {
Runnable runnable = invocation.getArgument(0);
runnable.run();
return CompletableFuture.completedFuture(null);
});
CompletableFuture<Void> activation = activate(world, provider);
assertFalse(activation.isDone());
verify(provider, never()).endStudioEntryBootstrap();
if (fail) {
IllegalStateException failure = new IllegalStateException("ring calculation failed");
rings.completeExceptionally(failure);
CompletionException reported = assertThrows(CompletionException.class, activation::join);
assertSame(failure, reported.getCause());
verify(provider, never()).endStudioEntryBootstrap();
} else {
rings.complete(null);
activation.join();
verify(provider).endStudioEntryBootstrap();
}
}
}
@SuppressWarnings("unchecked")
private CompletableFuture<Void> activate(World world, PlatformChunkGenerator provider) throws Exception {
Method method = StudioOpenCoordinator.class.getDeclaredMethod(
"endStudioEntryBootstrap", World.class, PlatformChunkGenerator.class);
method.setAccessible(true);
return (CompletableFuture<Void>) method.invoke(StudioOpenCoordinator.get(), world, provider);
}
}
@@ -0,0 +1,122 @@
package art.arcane.iris.studio.object;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator;
import org.bukkit.World;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class StudioCloseRecoveryTest {
@Test
public void failedGeneratorCloseKeepsMutationReservationWithoutRestartingServer() throws Exception {
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
IllegalStateException failure = new IllegalStateException("generator cleanup failed");
when(provider.closeAsync()).thenReturn(CompletableFuture.failedFuture(failure));
LifecycleOperationCoordinator coordinator = mock(LifecycleOperationCoordinator.class);
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
when(coordinator.acquire(any(), any(), anyString())).thenReturn(lease);
try (MockedStatic<LifecycleOperationCoordinator> lifecycle = mockStatic(LifecycleOperationCoordinator.class);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator);
StudioOpenCoordinator.StudioCloseResult result = close(provider, null).join();
assertSame(failure, result.failureCause());
configurator.verifyNoInteractions();
verify(lease, never()).close();
}
}
@Test
public void pendingGeneratorRetainsProjectAndMutationReservationUntilCleanupFinishes() throws Exception {
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
CompletableFuture<Void> generatorClose = new CompletableFuture<>();
when(provider.closeAsync()).thenReturn(generatorClose);
IrisProject project = mock(IrisProject.class);
LifecycleOperationCoordinator coordinator = mock(LifecycleOperationCoordinator.class);
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
when(coordinator.acquire(any(), any(), anyString())).thenReturn(lease);
try (MockedStatic<LifecycleOperationCoordinator> lifecycle = mockStatic(LifecycleOperationCoordinator.class);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator);
CompletableFuture<StudioOpenCoordinator.StudioCloseResult> close = close(provider, project);
assertFalse(close.isDone());
verifyNoInteractions(project);
verify(lease, never()).close();
generatorClose.complete(null);
close.join();
verify(project).setActiveProvider(null);
verify(project).setActiveOpenKind(null);
verify(lease).close();
configurator.verifyNoInteractions();
}
}
@Test
public void cancellingCloseResultDoesNotSkipCleanupOrLeaseRelease() throws Exception {
PlatformChunkGenerator provider = mock(PlatformChunkGenerator.class);
CompletableFuture<Void> generatorClose = new CompletableFuture<>();
when(provider.closeAsync()).thenReturn(generatorClose);
IrisProject project = mock(IrisProject.class);
LifecycleOperationCoordinator coordinator = mock(LifecycleOperationCoordinator.class);
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
when(coordinator.acquire(any(), any(), anyString())).thenReturn(lease);
try (MockedStatic<LifecycleOperationCoordinator> lifecycle = mockStatic(LifecycleOperationCoordinator.class);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
lifecycle.when(LifecycleOperationCoordinator::get).thenReturn(coordinator);
CompletableFuture<StudioOpenCoordinator.StudioCloseResult> result = close(provider, project);
assertTrue(result.cancel(false));
assertFalse(generatorClose.isDone());
verifyNoInteractions(project);
verify(lease, never()).close();
generatorClose.complete(null);
assertTrue(result.isCancelled());
verify(project).setActiveProvider(null);
verify(project).setActiveOpenKind(null);
verify(lease).close();
configurator.verifyNoInteractions();
}
}
@SuppressWarnings("unchecked")
private CompletableFuture<StudioOpenCoordinator.StudioCloseResult> close(
PlatformChunkGenerator provider,
IrisProject project
) throws Exception {
Method method = StudioOpenCoordinator.class.getDeclaredMethod("closeWorldCoordinated",
PlatformChunkGenerator.class, String.class, World.class, boolean.class, IrisProject.class);
method.setAccessible(true);
return (CompletableFuture<StudioOpenCoordinator.StudioCloseResult>) method.invoke(
StudioOpenCoordinator.get(), provider, null, null, false, project);
}
}
@@ -8,9 +8,11 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; 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.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.fail; import static org.junit.Assert.fail;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
@@ -77,47 +79,32 @@ public class StudioOpenCoordinatorCloseSequenceTest {
} }
@Test @Test
public void terminalTimeoutPreventsLateUnloadFromClosingOrDeleting() { public void slowCloseKeepsWaitingAndFinishesRemainingPhasesWithoutRestart() {
ArrayList<String> phases = new ArrayList<>(); ArrayList<String> phases = new ArrayList<>();
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
CompletableFuture<Void> unload = new CompletableFuture<>(); CompletableFuture<Void> unload = new CompletableFuture<>();
AtomicReference<Runnable> warning = new AtomicReference<>();
try (MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose(
() -> phase(phases, "evacuate"),
() -> {
phases.add("unload");
return unload;
},
() -> phase(phases, "close-generator"),
() -> phase(phases, "delete-folders"));
StudioOpenCoordinator.warnOnSlowClose(close, "studio-world", warning::set);
CompletableFuture<Void> close = StudioOpenCoordinator.sequenceStudioClose( warning.get().run();
() -> phase(phases, "evacuate"), assertFalse(close.isDone());
() -> { assertFalse(unload.isDone());
phases.add("unload"); assertEquals(List.of("evacuate", "unload"), phases);
return unload; configurator.verifyNoInteractions();
},
() -> phase(phases, "close-generator"),
() -> phase(phases, "delete-folders"),
terminalTimeout::get);
terminalTimeout.set(true); unload.complete(null);
unload.complete(null);
try {
close.join(); close.join();
fail("Expected terminal timeout");
} catch (CompletionException exception) {
assertEquals("Studio close stopped after its terminal timeout.", exception.getCause().getMessage());
}
assertEquals(List.of("evacuate", "unload"), phases);
}
@Test
public void closeFailureAfterUnloadBeginsRequestsTerminalRestart() {
try (MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class)) {
StudioOpenCoordinator.requestRestartAfterPartialClose("studio-world", true);
configurator.verify(() -> ServerConfigurator.restart(
"Studio close failed after world unload began for \"studio-world\"."));
}
}
@Test
public void closeFailureBeforeUnloadDoesNotRequestRestart() {
try (MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class)) {
StudioOpenCoordinator.requestRestartAfterPartialClose("studio-world", false);
assertEquals(List.of("evacuate", "unload", "close-generator", "delete-folders"), phases);
configurator.verifyNoInteractions(); configurator.verifyNoInteractions();
} }
} }
@@ -0,0 +1,89 @@
package art.arcane.iris.studio.view;
import art.arcane.iris.configuration.IrisSettings;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.awt.AWTEvent;
import java.awt.EventQueue;
import java.awt.Toolkit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertNull;
public class DesktopGuiLaunchTest {
private IrisSettings previousSettings;
private boolean previousSuppressed;
private RecordingEventQueue events;
@Before
public void suppressDesktop() throws Exception {
previousSettings = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
previousSuppressed = GuiHost.isDesktopSuppressed();
GuiHost.suppressDesktop(true);
events = new RecordingEventQueue();
Toolkit.getDefaultToolkit().getSystemEventQueue().push(events);
EventQueue.invokeAndWait(() -> {
});
}
@After
public void restoreDesktop() throws Exception {
EventQueue.invokeAndWait(events::remove);
GuiHost.suppressDesktop(previousSuppressed);
IrisSettings.settings = previousSettings;
}
@Test
public void mapLaunchDoesNotCreateAWindowWithoutDesktop() throws Exception {
VisionGUI.launch(null, null);
assertNoDesktopFailure();
}
@Test
public void noiseLaunchDoesNotCreateAWindowWithoutDesktop() throws Exception {
NoiseExplorerGUI.launch();
assertNoDesktopFailure();
}
@Test
public void generatorLaunchDoesNotCreateAWindowWithoutDesktop() throws Exception {
NoiseExplorerGUI.launchGeneratorKey("test", null, 12345L);
assertNoDesktopFailure();
}
@Test
public void imageMapLaunchDoesNotReadEngineWithoutDesktop() throws Exception {
ImageMapStudioGUI.launch(null);
assertNoDesktopFailure();
}
private void assertNoDesktopFailure() throws Exception {
EventQueue.invokeAndWait(() -> {
});
assertNull("Desktop launch raised an exception on the AWT event thread", events.failure.get());
}
private static final class RecordingEventQueue extends EventQueue {
private final AtomicReference<Throwable> failure = new AtomicReference<>();
@Override
protected void dispatchEvent(AWTEvent event) {
try {
super.dispatchEvent(event);
} catch (Throwable error) {
failure.compareAndSet(null, error);
}
}
private void remove() {
pop();
}
}
}
@@ -1,5 +1,7 @@
package art.arcane.iris.world.lifecycle; 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 art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
@@ -10,9 +12,13 @@ import org.junit.Test;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage; 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 java.util.function.Consumer;
import static org.junit.Assert.assertEquals; 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.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer; 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.mock;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -237,6 +244,96 @@ public class WorldLifecycleUnloadAsyncTest {
assertEquals("remembered", service.backendNameForWorld(worldKey)); assertEquals("remembered", service.backendNameForWorld(worldKey));
} }
@Test
public void settledUnloadRetainsTheBoundaryUntilBackendCompletionWithoutATerminalTimer() {
ControlledBackend remembered = new ControlledBackend("remembered");
WorldLifecycleService service = service(remembered, new ControlledBackend("fallback"));
NamespacedKey worldKey = new NamespacedKey("iris", "settled_boundary");
World world = world(worldKey);
service.rememberBackend(worldKey, remembered.backendName());
ArrayList<Runnable> timeouts = new ArrayList<>();
Executor captureTimeout = timeouts::add;
try (MockedStatic<CompletableFuture> futures = mockStatic(CompletableFuture.class, CALLS_REAL_METHODS)) {
futures.when(() -> CompletableFuture.delayedExecutor(120L, TimeUnit.SECONDS))
.thenReturn(captureTimeout);
CompletableFuture<Boolean> result = service.unloadSettledAsync(world, false);
CompletionStage<Boolean> boundary = WorldUnloadBoundaryRegistry.claim(worldKey.toString());
assertNotNull(boundary);
assertFalse(boundary.toCompletableFuture().isDone());
assertFalse(result.isDone());
assertTrue(timeouts.isEmpty());
assertEquals("remembered", service.backendNameForWorld(worldKey));
remembered.complete(true);
assertTrue(result.join());
assertTrue(boundary.toCompletableFuture().join());
assertEquals("fallback", service.backendNameForWorld(worldKey));
}
}
@Test
public void cancellingSettledUnloadResultPreservesBackendCompletionBookkeeping() {
ControlledBackend remembered = new ControlledBackend("remembered");
WorldLifecycleService service = service(remembered, new ControlledBackend("fallback"));
NamespacedKey worldKey = new NamespacedKey("iris", "cancelled_settlement");
World world = world(worldKey);
service.rememberBackend(worldKey, remembered.backendName());
CompletableFuture<Boolean> result = service.unloadSettledAsync(world, false);
CompletionStage<Boolean> boundary = WorldUnloadBoundaryRegistry.claim(worldKey.toString());
assertNotNull(boundary);
assertTrue(result.cancel(false));
assertFalse(boundary.toCompletableFuture().isDone());
assertEquals("remembered", service.backendNameForWorld(worldKey));
remembered.complete(true);
assertTrue(result.isCancelled());
assertTrue(boundary.toCompletableFuture().join());
assertEquals("fallback", service.backendNameForWorld(worldKey));
}
@Test
public void guardedUnloadKeepsItsTimeoutAndTracksLateBackendSuccess() {
ControlledBackend remembered = new ControlledBackend("remembered");
WorldLifecycleService service = service(remembered, new ControlledBackend("fallback"));
NamespacedKey worldKey = new NamespacedKey("iris", "late_settlement");
World world = world(worldKey);
service.rememberBackend(worldKey, remembered.backendName());
ArrayList<Runnable> timeouts = new ArrayList<>();
Executor captureTimeout = timeouts::add;
try (MockedStatic<CompletableFuture> futures = mockStatic(CompletableFuture.class, CALLS_REAL_METHODS);
MockedStatic<ServerConfigurator> configurator = mockStatic(ServerConfigurator.class);
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class)) {
futures.when(() -> CompletableFuture.delayedExecutor(120L, TimeUnit.SECONDS))
.thenReturn(captureTimeout);
CompletableFuture<Boolean> result = service.unloadAsync(world, false);
CompletionStage<Boolean> boundary = WorldUnloadBoundaryRegistry.claim(worldKey.toString());
assertEquals(1, timeouts.size());
timeouts.getFirst().run();
CompletionException failure = assertThrows(CompletionException.class, result::join);
assertTrue(failure.getCause() instanceof TimeoutException);
configurator.verify(() -> ServerConfigurator.restart("World unload timed out for \"late_settlement\"."));
assertFalse(boundary.toCompletableFuture().isDone());
assertEquals("remembered", service.backendNameForWorld(worldKey));
remembered.complete(true);
assertTrue(boundary.toCompletableFuture().join());
assertTrue(result.isCompletedExceptionally());
assertEquals("fallback", service.backendNameForWorld(worldKey));
}
}
private static WorldLifecycleService service( private static WorldLifecycleService service(
ControlledBackend remembered, ControlledBackend remembered,
ControlledBackend fallback ControlledBackend fallback