From b1fbede36404bcae6c95d1f4425df151cc78ed7f Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 29 Sep 2026 08:44:07 -0400 Subject: [PATCH] Maintenance after evacuation - Mantle-bypass maintenance starts only after players leave the world - This covers studio close, studio disable, world remove, world unload and create rollback - Maintenance ends only when it was entered - Folia no longer treats a refused chunk requested during evacuation as a chunk system crash --- .../art/arcane/iris/command/CommandIris.java | 14 +++++-- .../art/arcane/iris/studio/StudioSVC.java | 4 +- .../studio/object/StudioOpenCoordinator.java | 18 +++++++-- .../art/arcane/iris/world/IrisCreator.java | 13 +++++-- .../lifecycle/IrisWorldRemovalService.java | 22 ++++++++--- ...tudioOpenCoordinatorCloseSequenceTest.java | 38 ++++++++++++++++--- .../IrisWorldRemovalServiceTest.java | 16 +++++++- 7 files changed, 102 insertions(+), 23 deletions(-) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java index f3c15c99b..1c09dfd01 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandIris.java @@ -511,7 +511,9 @@ public class CommandIris implements DirectorExecutor { return; } PlatformChunkGenerator generator = IrisToolbelt.access(world); - IrisToolbelt.beginWorldMaintenance(world, "world-unload", true); + // Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk + // system failure, so it starts only once no player is left to request one. + AtomicBoolean maintenanceEntered = new AtomicBoolean(false); try { AtomicBoolean terminalTimeout = new AtomicBoolean(false); CompletableFuture sequence = IrisToolbelt.evacuateAsync(world) @@ -523,6 +525,8 @@ public class CommandIris implements DirectorExecutor { if (!Boolean.TRUE.equals(evacuated)) { return CompletableFuture.completedFuture(false); } + IrisToolbelt.beginWorldMaintenance(world, "world-unload", true); + maintenanceEntered.set(true); return WorldLifecycleService.get().unloadAsync(world, true); }) .thenCompose(unloaded -> { @@ -537,7 +541,9 @@ public class CommandIris implements DirectorExecutor { }); guardUnloadCompletion(sequence, terminalTimeout, world.getName()) .whenComplete((unloaded, throwable) -> { - IrisToolbelt.endWorldMaintenance(world, "world-unload", true); + if (maintenanceEntered.get()) { + IrisToolbelt.endWorldMaintenance(world, "world-unload", true); + } lease.close(); Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable); if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) { @@ -546,7 +552,9 @@ public class CommandIris implements DirectorExecutor { J.s(response); }); } catch (Exception e) { - IrisToolbelt.endWorldMaintenance(world, "world-unload", true); + if (maintenanceEntered.get()) { + IrisToolbelt.endWorldMaintenance(world, "world-unload", true); + } lease.close(); responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e); diff --git a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java index cd43d7c16..9ebc47227 100644 --- a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java @@ -1098,12 +1098,14 @@ public class StudioSVC implements IrisService { } private void destroyStudioWorld(World world, PlatformChunkGenerator generator) { - IrisToolbelt.beginWorldMaintenance(world, "studio-disable", true); try { IrisToolbelt.evacuate(world); } catch (Throwable e) { IrisLogging.reportError("Failed to evacuate studio world \"" + world.getName() + "\" during shutdown cleanup.", e); } + // Entered after the evacuation: maintenance that forbids mantle stages refuses every chunk, and Folia + // treats a refused chunk as a chunk system failure. + IrisToolbelt.beginWorldMaintenance(world, "studio-disable", true); try { WorldLifecycleService.get().unloadAsync(world, false) .thenCompose(unloaded -> { diff --git a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java index 4acd70858..95985aeac 100644 --- a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java @@ -459,12 +459,14 @@ public final class StudioOpenCoordinator { ) { AtomicBoolean unloadConfirmed = new AtomicBoolean(false); AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder); - if (world != null) { - IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); - } CompletableFuture sequence = sequenceStudioClose( () -> evacuateWorldFamily(worldName, world), + () -> { + if (world != null) { + IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); + } + }, () -> unloadWorldFamily(worldName, world).thenRun(() -> unloadConfirmed.set(true)), () -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(), () -> deleteFolder @@ -491,14 +493,22 @@ public final class StudioOpenCoordinator { }); } + /** + * Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk + * system failure, so it is entered only once no player is left to request one. + */ static CompletableFuture sequenceStudioClose( Supplier> evacuate, + Runnable enterMaintenance, Supplier> unload, Supplier> closeGenerator, Supplier> deleteFolders ) { return invokePhase(evacuate) - .thenCompose(ignored -> invokePhase(unload)) + .thenCompose(ignored -> { + enterMaintenance.run(); + return invokePhase(unload); + }) .thenCompose(ignored -> invokePhase(closeGenerator)) .thenCompose(ignored -> invokePhase(deleteFolders)); } diff --git a/core/src/main/java/art/arcane/iris/world/IrisCreator.java b/core/src/main/java/art/arcane/iris/world/IrisCreator.java index e8d90b201..2040c60a9 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/world/IrisCreator.java @@ -756,12 +756,17 @@ public class IrisCreator { } } if (activeWorld != null) { - IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true); + boolean maintenanceEntered = false; try { PlatformChunkGenerator generator = IrisToolbelt.access(activeWorld); boolean evacuated = Boolean.TRUE.equals(IrisToolbelt.evacuateAsync(activeWorld) .get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)); - if (!evacuated) { + if (evacuated) { + // Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk + // as a chunk system failure, so it starts only once no player is left to request one. + IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true); + maintenanceEntered = true; + } else { safeToDelete = false; failure.addSuppressed(new IllegalStateException( "Rollback could not evacuate world \"" + name + "\".")); @@ -789,7 +794,9 @@ public class IrisCreator { ServerConfigurator.restart("World creation rollback timed out for \"" + name + "\"."); } } finally { - IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback", true); + if (maintenanceEntered) { + IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback", true); + } } } diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java b/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java index 81ffb3db5..bbc80f374 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalService.java @@ -152,15 +152,21 @@ public final class IrisWorldRemovalService { boolean deleteFiles, AtomicBoolean terminal ) { + // Maintenance that forbids mantle stages refuses every chunk, and Folia treats a refused chunk as a chunk + // system failure, so it starts only once no player is left to request one. + AtomicBoolean maintenanceEntered = new AtomicBoolean(false); CompletableFuture operation = phase( - RemovalStatus.RESOLUTION_FAILED, - () -> backend.beginMaintenance(resolved).thenApply(ignored -> resolved), + RemovalStatus.TELEPORT_FAILED, + () -> backend.evacuatePlayers(resolved).thenApply(ignored -> resolved), terminal ) .thenCompose(activeWorld -> phase( - RemovalStatus.TELEPORT_FAILED, - () -> backend.evacuatePlayers(activeWorld) - .thenApply(ignored -> activeWorld), + RemovalStatus.RESOLUTION_FAILED, + () -> { + CompletableFuture begun = backend.beginMaintenance(activeWorld); + maintenanceEntered.set(true); + return begun.thenApply(ignored -> activeWorld); + }, terminal )) .thenCompose(activeWorld -> phase( @@ -198,7 +204,7 @@ public final class IrisWorldRemovalService { ); })); - return operation.handle((result, throwable) -> backend.endMaintenance(resolved) + return operation.handle((result, throwable) -> endMaintenance(resolved, maintenanceEntered.get()) .handle((ignored, cleanupFailure) -> { if (cleanupFailure != null) { Throwable unwrappedCleanup = unwrap(cleanupFailure); @@ -216,6 +222,10 @@ public final class IrisWorldRemovalService { })).thenCompose(result -> result); } + private CompletableFuture endMaintenance(ResolvedWorld resolved, boolean entered) { + return entered ? backend.endMaintenance(resolved) : CompletableFuture.completedFuture(null); + } + private CompletableFuture validateResolvedWorld(ResolvedWorld resolvedWorld) { if (!resolvedWorld.managed()) { return CompletableFuture.failedFuture(new RemovalFailure( diff --git a/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java b/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java index d093be1b1..0ff45e3d0 100644 --- a/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java +++ b/core/src/test/java/art/arcane/iris/studio/object/StudioOpenCoordinatorCloseSequenceTest.java @@ -24,12 +24,13 @@ public class StudioOpenCoordinatorCloseSequenceTest { StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), + () -> phases.add("maintenance"), () -> phase(phases, "unload"), () -> phase(phases, "close-generator"), () -> phase(phases, "delete-folders") ).join(); - assertEquals(List.of("evacuate", "unload", "close-generator", "delete-folders"), phases); + assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator", "delete-folders"), phases); } @Test @@ -40,6 +41,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { try { StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), + () -> phases.add("maintenance"), () -> { phases.add("unload"); return CompletableFuture.failedFuture(failure); @@ -52,7 +54,31 @@ public class StudioOpenCoordinatorCloseSequenceTest { assertSame(failure, exception.getCause()); } - assertEquals(List.of("evacuate", "unload"), phases); + assertEquals(List.of("evacuate", "maintenance", "unload"), phases); + } + + @Test + public void evacuationFailureNeverEntersMaintenance() { + ArrayList phases = new ArrayList<>(); + IllegalStateException failure = new IllegalStateException("evacuation failed"); + + try { + StudioOpenCoordinator.sequenceStudioClose( + () -> { + phases.add("evacuate"); + return CompletableFuture.failedFuture(failure); + }, + () -> phases.add("maintenance"), + () -> phase(phases, "unload"), + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folders") + ).join(); + fail("Expected evacuation failure"); + } catch (CompletionException exception) { + assertSame(failure, exception.getCause()); + } + + assertEquals(List.of("evacuate"), phases); } @Test @@ -63,6 +89,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { try { StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), + () -> phases.add("maintenance"), () -> phase(phases, "unload"), () -> { phases.add("close-generator"); @@ -75,7 +102,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { assertSame(failure, exception.getCause()); } - assertEquals(List.of("evacuate", "unload", "close-generator"), phases); + assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator"), phases); } @Test @@ -87,6 +114,7 @@ public class StudioOpenCoordinatorCloseSequenceTest { MockedStatic logging = mockStatic(IrisLogging.class)) { CompletableFuture close = StudioOpenCoordinator.sequenceStudioClose( () -> phase(phases, "evacuate"), + () -> phases.add("maintenance"), () -> { phases.add("unload"); return unload; @@ -98,13 +126,13 @@ public class StudioOpenCoordinatorCloseSequenceTest { warning.get().run(); assertFalse(close.isDone()); assertFalse(unload.isDone()); - assertEquals(List.of("evacuate", "unload"), phases); + assertEquals(List.of("evacuate", "maintenance", "unload"), phases); configurator.verifyNoInteractions(); unload.complete(null); close.join(); - assertEquals(List.of("evacuate", "unload", "close-generator", "delete-folders"), phases); + assertEquals(List.of("evacuate", "maintenance", "unload", "close-generator", "delete-folders"), phases); configurator.verifyNoInteractions(); } } diff --git a/core/src/test/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalServiceTest.java b/core/src/test/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalServiceTest.java index a09448002..702768a96 100644 --- a/core/src/test/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalServiceTest.java +++ b/core/src/test/java/art/arcane/iris/world/lifecycle/IrisWorldRemovalServiceTest.java @@ -67,12 +67,26 @@ public class IrisWorldRemovalServiceTest { assertTrue(result.succeeded()); assertTrue(result.registryChanged()); assertEquals( - List.of("resolve", "begin", "evacuate", "unload", "close", "unregister", "unregisterRegistry", "end"), + List.of("resolve", "evacuate", "begin", "unload", "close", "unregister", "unregisterRegistry", "end"), backend.operations ); assertTrue(coordinator.isIdle()); } + @Test + public void mantleBypassMaintenanceStartsOnlyAfterPlayersLeave() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + FakeBackend backend = new FakeBackend(target("evacuation-failed")); + backend.evacuation = CompletableFuture.failedFuture(new IllegalStateException("no destination world")); + IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend); + + IrisWorldRemovalService.RemovalResult result = service.remove("evacuation-failed", true).join(); + + assertEquals(IrisWorldRemovalService.RemovalStatus.TELEPORT_FAILED, result.status()); + assertEquals(List.of("resolve", "evacuate"), backend.operations); + assertTrue(coordinator.isIdle()); + } + @Test public void configurationFailureStopsBeforeDeletionAndRetainsCause() throws Exception { LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();