From 2b124a2a2540494e7242f8e61e28f0762091e740 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 04:46:38 -0400 Subject: [PATCH] Fixed world reopen admission - Generation admission retires after every runtime closes - Engine setup retains admission across activation replacements - Failed generator closes stay sealed and retryable - Regression tests cover reopening and pending promotions --- .../iris/modded/ModdedWorldEngines.java | 73 +++++------ .../iris/engine/EngineShutdownSequence.java | 37 +++++- .../art/arcane/iris/engine/IrisEngine.java | 1 + .../engine/history/GenerationAdmission.java | 79 +++++++++++- .../engine/history/GenerationHistory.java | 4 + .../engine/platform/BukkitChunkGenerator.java | 119 +++++++++--------- .../iris/engine/EngineShutdownDrainTest.java | 119 ++++++++++++++++++ .../IrisEngineGenerationRuntimeScopeTest.java | 24 ++-- .../history/GenerationAdmissionTest.java | 94 ++++++++++++++ .../BukkitChunkGeneratorCloseTest.java | 107 ++++++++++++++++ 10 files changed, 548 insertions(+), 109 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index 6066f3684..9063dea2f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -28,6 +28,7 @@ import art.arcane.iris.engine.history.GenerationActivation; import art.arcane.iris.engine.history.GenerationEpoch; import art.arcane.iris.engine.history.GenerationEpochContractFactory; import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationAdmission; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.history.IrisBoundarySignatureSampler; import art.arcane.iris.engine.history.TransitionGenerationPlan; @@ -191,44 +192,46 @@ public final class ModdedWorldEngines { ModdedGenerationHistoryStorage.ActivePack opened ) throws IOException { GenerationHistory history = opened.history(); - history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); - opened = ModdedGenerationHistoryStorage.resolveActive(history); - long openedActivationId = history.activeActivation().activationId(); - IrisEngine candidate = buildEngine(level, seed, opened); - boolean ready = false; - try { - GenerationHistoryRuntimeRouter router = candidate.attachGenerationHistory( - history, - IrisBoundarySignatureSampler.INSTANCE, - IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() - ); - router.preloadActiveRuntimes(); - if (history.activeActivation().activationId() != openedActivationId) { - close(candidate); - ModdedGenerationHistoryStorage.ActivePack promoted = - ModdedGenerationHistoryStorage.resolveActive(history); - candidate = buildEngine(level, seed, promoted); - router = GenerationHistoryRuntimeRouter.attach( - candidate, + try (GenerationAdmission.RuntimeLease startupAdmission = history.retainRuntime()) { + history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); + opened = ModdedGenerationHistoryStorage.resolveActive(history); + long openedActivationId = history.activeActivation().activationId(); + IrisEngine candidate = buildEngine(level, seed, opened); + boolean ready = false; + try { + GenerationHistoryRuntimeRouter router = candidate.attachGenerationHistory( history, - IrisBoundarySignatureSampler.INSTANCE + IrisBoundarySignatureSampler.INSTANCE, + IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() ); router.preloadActiveRuntimes(); - } - requireReady(candidate, level, ModdedGenerationMode.PERSISTENT_RESTORE); - ready = true; - ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{} activation={}", - level.dimension().identifier(), - candidate.getData().getDataFolder().getAbsolutePath(), - candidate.getDimension().getLoadKey(), - seed, - candidate.getDimension().getMinHeight(), - candidate.getDimension().getMaxHeight(), - history.activeActivation().activationId()); - return candidate; - } finally { - if (!ready) { - close(candidate); + if (history.activeActivation().activationId() != openedActivationId) { + close(candidate); + ModdedGenerationHistoryStorage.ActivePack promoted = + ModdedGenerationHistoryStorage.resolveActive(history); + candidate = buildEngine(level, seed, promoted); + router = GenerationHistoryRuntimeRouter.attach( + candidate, + history, + IrisBoundarySignatureSampler.INSTANCE + ); + router.preloadActiveRuntimes(); + } + requireReady(candidate, level, ModdedGenerationMode.PERSISTENT_RESTORE); + ready = true; + ModdedIrisLog.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{} activation={}", + level.dimension().identifier(), + candidate.getData().getDataFolder().getAbsolutePath(), + candidate.getDimension().getLoadKey(), + seed, + candidate.getDimension().getMinHeight(), + candidate.getDimension().getMaxHeight(), + history.activeActivation().activationId()); + return candidate; + } finally { + if (!ready) { + close(candidate); + } } } } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java index 70545c13d..b7d0a5add 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java @@ -27,11 +27,16 @@ import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; import art.arcane.iris.engine.framework.PreservationRegistry; import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.history.GenerationAdmission; +import art.arcane.iris.engine.history.GenerationHistory; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import java.util.Collections; import java.util.IdentityHashMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Iterator; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -44,6 +49,7 @@ final class EngineShutdownSequence { private static final long CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS = 5000L; private final IrisEngine engine; + private final List generationAdmissions = new ArrayList<>(1); private final Set incompleteAssemblies = ConcurrentHashMap.newKeySet(); private final Set unpublishedRuntimes = Collections.synchronizedSet( Collections.newSetFromMap(new IdentityHashMap<>())); @@ -58,6 +64,10 @@ final class EngineShutdownSequence { this.engine = engine; } + void retainGenerationHistory(GenerationHistory history) { + generationAdmissions.add(history.retainRuntime()); + } + void close() { if (!engine.beginShutdown()) { return; @@ -161,9 +171,12 @@ final class EngineShutdownSequence { && targetReleased && engineDataReleased && preservationReleased) { - engine.closed = true; - engine.lifecycleState = LifecycleState.CLOSED; - IrisLogging.debug("Engine Fully Shutdown!"); + failure = releaseGenerationAdmissions(failure); + if (failure == null) { + engine.closed = true; + engine.lifecycleState = LifecycleState.CLOSED; + IrisLogging.debug("Engine Fully Shutdown!"); + } } } if (failure != null) { @@ -171,6 +184,19 @@ final class EngineShutdownSequence { } } + private Throwable releaseGenerationAdmissions(Throwable failure) { + Iterator admissions = generationAdmissions.iterator(); + while (admissions.hasNext()) { + GenerationAdmission.RuntimeLease admission = admissions.next(); + Throwable admissionFailure = runCleanup(null, admission::close); + failure = appendFailure(failure, admissionFailure); + if (admissionFailure == null) { + admissions.remove(); + } + } + return failure; + } + private void reportIncompleteClose(Throwable failure) { IrisLogging.error("Iris engine shutdown remains incomplete after cleanup failures for " + engine.getWorld().name() + "."); IrisLogging.reportError(failure); @@ -218,7 +244,10 @@ final class EngineShutdownSequence { cleanupFailure = releasePreservation(cleanupFailure); } if (runtimeReleased && targetReleased && engineDataReleased && preservationReleased) { - engine.closed = true; + cleanupFailure = releaseGenerationAdmissions(cleanupFailure); + if (generationAdmissions.isEmpty()) { + engine.closed = true; + } } } if (cleanupFailure != null && cleanupFailure != original) { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index cdde04c18..ae0e99fa4 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -1010,6 +1010,7 @@ public class IrisEngine implements Engine { if (generationHistoryRuntimeRouter != null) { throw new IllegalStateException("Iris engine already has a generation-history runtime router."); } + shutdownSequence.retainGenerationHistory(required.history()); generationHistoryRuntimeRouter = required; generationHistoryRoutingRequired = true; } diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java index 7cbb0c065..0a3713e46 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationAdmission.java @@ -16,7 +16,12 @@ public final class GenerationAdmission { Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot") .toAbsolutePath() .normalize(); - this.state = STATES.computeIfAbsent(root, ignored -> new State()); + this.state = STATES.computeIfAbsent(root, State::new); + } + + public RuntimeLease retainRuntime() { + state.retainRuntime(); + return new RuntimeLease(state); } public StageLease enterStage() { @@ -34,6 +39,23 @@ public final class GenerationAdmission { return new CutoverLease(state); } + public static final class RuntimeLease implements AutoCloseable { + private final State state; + private boolean closed; + + private RuntimeLease(State state) { + this.state = state; + } + + @Override + public synchronized void close() { + if (!closed) { + state.releaseRuntime(); + closed = true; + } + } + } + public static final class StageLease implements AutoCloseable { private final State state; private final AtomicBoolean closed; @@ -69,26 +91,71 @@ public final class GenerationAdmission { } private static final class State { + private final Path root; private final ReentrantLock lock; private final Condition changed; + private int runtimeOwners; + private int waitingStages; private int activeStages; private int waitingCutovers; private boolean cutoverActive; private boolean generationAdmissionOpened; + private boolean closed; - private State() { + private State(Path root) { + this.root = root; this.lock = new ReentrantLock(true); this.changed = lock.newCondition(); } + private void retainRuntime() { + lock.lock(); + try { + requireOpen(); + runtimeOwners++; + } finally { + lock.unlock(); + } + } + + private void releaseRuntime() { + lock.lock(); + try { + requireOpen(); + if (runtimeOwners == 1) { + if (activeStages > 0 || waitingStages > 0 || waitingCutovers > 0 || cutoverActive) { + throw new IllegalStateException("Generation admission still has active stages or cutovers."); + } + closed = true; + STATES.remove(root, this); + } + runtimeOwners--; + } finally { + lock.unlock(); + } + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Generation admission belongs to a closed runtime."); + } + } + private void enterStage() { lock.lock(); try { + requireOpen(); generationAdmissionOpened = true; - while (cutoverActive || waitingCutovers > 0) { - changed.awaitUninterruptibly(); + waitingStages++; + try { + while (cutoverActive || waitingCutovers > 0) { + changed.awaitUninterruptibly(); + requireOpen(); + } + activeStages++; + } finally { + waitingStages--; } - activeStages++; } finally { lock.unlock(); } @@ -112,6 +179,7 @@ public final class GenerationAdmission { private void beginCutover(boolean startupOnly) { lock.lock(); try { + requireOpen(); if (startupOnly && generationAdmissionOpened) { throw new IllegalStateException( "Generation activation promotion is only allowed before generation admission opens." @@ -121,6 +189,7 @@ public final class GenerationAdmission { try { while (cutoverActive || activeStages > 0) { changed.awaitUninterruptibly(); + requireOpen(); } cutoverActive = true; } finally { diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java index 513c5cd99..cdcf7111d 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationHistory.java @@ -64,6 +64,10 @@ public final class GenerationHistory { validateReferencedState(); } + public GenerationAdmission.RuntimeLease retainRuntime() { + return admission.retainRuntime(); + } + public SavedBiomeStore savedBiomes() { return savedBiomes; } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index bdcdf77bf..01fa4d8de 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -48,6 +48,7 @@ import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.history.GenerationActivation; import art.arcane.iris.engine.history.GenerationEpoch; import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationAdmission; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; import art.arcane.iris.engine.history.IrisBoundarySignatureSampler; import art.arcane.iris.engine.history.TransitionGenerationPlan; @@ -396,66 +397,68 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private void setupEngine() { - lastMode = StudioMode.NORMAL; - lastJigsawStudioRequestId = null; - if (generationHistory != null) { - try { - generationHistory.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); - targetCache.reset(); - } catch (IOException failure) { - throw new IllegalStateException("Unable to prepare saved Iris terrain for the current generator.", failure); - } - } - EngineTarget engineTarget = getTarget(); - String packKey = engineTarget.getDimension().getLoadKey(); - JigsawStudioActivation.Request request = studio - ? JigsawStudioActivation.getGeneratorRequest(packKey) - : null; - JigsawStudioSession session = request == null - ? null - : JigsawStudioActivation.getGeneratorSession(packKey); - jigsawStudioActive = request != null - && session != null - && session.sessionId().equals(request.requestId()); - objectStudioActive = studio && ObjectStudioActivation.isActive(packKey); - IrisEngine createdEngine = createEngine(engineTarget); - if (generationHistory != null) { - long initialActivationId = generationHistory.activeActivation().activationId(); - int transitionWidthBlocks = IrisSettings.get() - .getGenerator() - .getGenerationTransitionWidthBlocks(); - try { - createdEngine.attachGenerationHistory( - generationHistory, - IrisBoundarySignatureSampler.INSTANCE, - transitionWidthBlocks); - if (generationHistory.activeActivation().activationId() != initialActivationId) { - createdEngine.close(); + try (GenerationAdmission.RuntimeLease startupAdmission = generationHistory == null ? null : generationHistory.retainRuntime()) { + lastMode = StudioMode.NORMAL; + lastJigsawStudioRequestId = null; + if (generationHistory != null) { + try { + generationHistory.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); targetCache.reset(); - createdEngine = createEngine(loadActiveGenerationHistoryTarget()); + } catch (IOException failure) { + throw new IllegalStateException("Unable to prepare saved Iris terrain for the current generator.", failure); + } + } + EngineTarget engineTarget = getTarget(); + String packKey = engineTarget.getDimension().getLoadKey(); + JigsawStudioActivation.Request request = studio + ? JigsawStudioActivation.getGeneratorRequest(packKey) + : null; + JigsawStudioSession session = request == null + ? null + : JigsawStudioActivation.getGeneratorSession(packKey); + jigsawStudioActive = request != null + && session != null + && session.sessionId().equals(request.requestId()); + objectStudioActive = studio && ObjectStudioActivation.isActive(packKey); + IrisEngine createdEngine = createEngine(engineTarget); + if (generationHistory != null) { + long initialActivationId = generationHistory.activeActivation().activationId(); + int transitionWidthBlocks = IrisSettings.get() + .getGenerator() + .getGenerationTransitionWidthBlocks(); + try { createdEngine.attachGenerationHistory( generationHistory, IrisBoundarySignatureSampler.INSTANCE, transitionWidthBlocks); - } - } catch (Throwable failure) { - try { - createdEngine.close(); - } catch (Throwable closeFailure) { - if (failure != closeFailure) { - failure.addSuppressed(closeFailure); + if (generationHistory.activeActivation().activationId() != initialActivationId) { + createdEngine.close(); + targetCache.reset(); + createdEngine = createEngine(loadActiveGenerationHistoryTarget()); + createdEngine.attachGenerationHistory( + generationHistory, + IrisBoundarySignatureSampler.INSTANCE, + transitionWidthBlocks); } + } catch (Throwable failure) { + try { + createdEngine.close(); + } catch (Throwable closeFailure) { + if (failure != closeFailure) { + failure.addSuppressed(closeFailure); + } + } + throw propagateEngineSetupFailure(failure); } - throw propagateEngineSetupFailure(failure); } + createdEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures( + isAuthoringStudio(), + studioEntryBootstrapActive.get(), + initializationFailure != null)); + engine = createdEngine; + populators.clear(); + targetCache.reset(); } - createdEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures( - isAuthoringStudio(), - studioEntryBootstrapActive.get(), - initializationFailure != null)); - engine = createdEngine; - populators.clear(); - targetCache.reset(); } private EngineTarget loadActiveGenerationHistoryTarget() throws IOException { @@ -672,7 +675,6 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public CompletableFuture closeAsync() { - closing = true; // Outside the exclusive-control block so every close path detaches the WorldInit // listener, including a rollback where the world never materialized. Guarded: with no // hosted plugin (unit tests, teardown) volmitPlugin() throws, and that must not stop @@ -689,6 +691,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } + boolean alreadyClosing = closing; + closing = true; CompletableFuture operation; try { operation = withExclusiveControlFuture(() -> { @@ -712,11 +716,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun populators.clear(); }); } catch (Throwable throwable) { - future.completeExceptionally(throwable); + if (!alreadyClosing) { + // A failed close must stay retryable; leaving closing latched would permanently + // reject generation for a world that may still be loaded. + closing = false; + } closeFuture.compareAndSet(future, null); - // A failed close must stay retryable; leaving closing latched would permanently - // reject generation for a world that may still be loaded. - closing = false; + future.completeExceptionally(throwable); return future; } @@ -727,6 +733,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun // The close body already ran and tore the engine down; unlike the pre-dispatch // failure above, resetting the gate here would advertise a healthy generator // over a CLOSED or FAILED engine. Stay latched and surface the failure. + closeFuture.compareAndSet(future, null); future.completeExceptionally(throwable); } }); diff --git a/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java b/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java index d44af2da6..4a5b58410 100644 --- a/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java +++ b/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java @@ -12,6 +12,13 @@ import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionManager; import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; import art.arcane.iris.engine.history.GenerationKernelRegistry; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationAdmission; +import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.GenerationEpoch; +import art.arcane.iris.engine.history.GenerationEpochContractFactory; +import art.arcane.iris.engine.history.GenerationPackFingerprint; +import art.arcane.iris.engine.history.GenerationRegistryContract; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.framework.EngineMode; import art.arcane.iris.engine.mantle.EngineMantle; @@ -22,16 +29,21 @@ import art.arcane.iris.spi.PlatformRegistries; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; import org.mockito.InOrder; import org.mockito.MockedStatic; import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -42,6 +54,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.same; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doAnswer; @@ -58,6 +71,9 @@ import static org.mockito.Mockito.when; public class EngineShutdownDrainTest { private static IrisPlatform previousPlatform; + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @BeforeClass public static void bindPlatform() { previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; @@ -77,6 +93,100 @@ public class EngineShutdownDrainTest { } } + @Test + public void successfulEngineCloseAllowsTheSameWorldToPrepareGenerationAgain() throws Exception { + GenerationHistory history = history(); + ShutdownFixture fixture = new ShutdownFixture(); + fixture.attachHistory(history); + try (GenerationHistory.GenerationStage ignored = history.openStage(2, 3)) { + } + assertThrows(IllegalStateException.class, () -> history.prepareCurrentGenerator(32)); + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + assertTrue(fixture.engine.closed); + GenerationHistory reopened = GenerationHistory.open(history.paths().dimensionRoot()); + reopened.prepareCurrentGenerator(32); + try (GenerationHistory.GenerationStage ignored = reopened.openStage(3, 4)) { + } + } + + @Test + public void failedEngineCloseKeepsAdmissionUntilSuccessfulRetry() throws Exception { + GenerationHistory history = history(); + ShutdownFixture fixture = new ShutdownFixture(); + fixture.attachHistory(history); + try (GenerationHistory.GenerationStage ignored = history.openStage(2, 3)) { + } + doThrow(new IllegalStateException("Planner still active")).when(fixture.complex).close(); + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + assertThrows(IllegalStateException.class, fixture.shutdown::close); + GenerationHistory premature = GenerationHistory.open(history.paths().dimensionRoot()); + assertThrows(IllegalStateException.class, () -> premature.prepareCurrentGenerator(32)); + doNothing().when(fixture.complex).close(); + fixture.shutdown.close(); + } + GenerationHistory reopened = GenerationHistory.open(history.paths().dimensionRoot()); + reopened.prepareCurrentGenerator(32); + } + + @Test + public void startupOwnershipSpansReplacementEnginesUsingTheSameHistory() throws Exception { + GenerationHistory history = history(); + ShutdownFixture first = new ShutdownFixture(); + ShutdownFixture replacement = new ShutdownFixture(); + try (GenerationAdmission.RuntimeLease startup = history.retainRuntime(); + MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + first.attachHistory(history); + first.shutdown.close(); + history.prepareCurrentGenerator(32); + replacement.attachHistory(history); + } + try (GenerationHistory.GenerationStage ignored = history.openStage(2, 3)) { + } + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + replacement.shutdown.close(); + } + GenerationHistory.open(history.paths().dimensionRoot()).prepareCurrentGenerator(32); + } + + @Test + public void reopenedWorldPromotesPendingGenerationAfterTheOldEngineCloses() throws Exception { + GenerationHistory history = history(); + ShutdownFixture fixture = new ShutdownFixture(); + fixture.attachHistory(history); + try (GenerationHistory.GenerationStage ignored = history.openStage(2, 3)) { + } + Path replacementPack = temporaryFolder.newFolder("replacement-pack").toPath(); + Files.createDirectories(replacementPack.resolve("dimensions")); + Files.writeString(replacementPack.resolve("dimensions/main.json"), "{\"name\":\"replacement\"}"); + history.stageUpdate(replacementPack, + GenerationPackFingerprint.compute(replacementPack, GenerationPackFingerprint.CURRENT_VERSION), + history.activeEpoch().dimensionContract(), GenerationRegistryContract.empty(), 32); + assertThrows(IllegalStateException.class, () -> history.prepareCurrentGenerator(32)); + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + GenerationHistory reopened = GenerationHistory.open(history.paths().dimensionRoot()); + reopened.prepareCurrentGenerator(32); + assertEquals(2L, reopened.activeActivation().activationId()); + assertFalse(reopened.pendingActivation().isPresent()); + } + + private GenerationHistory history() throws Exception { + Path world = temporaryFolder.newFolder("history-world").toPath(); + Path pack = temporaryFolder.newFolder("history-pack").toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.writeString(pack.resolve("dimensions/main.json"), "{}"); + GenerationEpoch.DimensionContract contract = new GenerationEpoch.DimensionContract( + "overworld", "iris:overworld_type", "NORMAL", "OVERWORLD", 127, + -64, 384, 384, 1D, false, "none", 0, "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, "c".repeat(64)); + return GenerationHistory.create(world, pack, + GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION), + 42L, contract, GenerationRegistryContract.empty()); + } + @Test public void failedPlannerDrainRetainsRuntimeStorage() { GenerationRuntime runtime = mock(GenerationRuntime.class); @@ -491,6 +601,15 @@ public class EngineShutdownDrainTest { return kernel; } + private void attachHistory(GenerationHistory history) throws Exception { + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(router.engine()).thenReturn(engine); + when(router.history()).thenReturn(history); + setField(engine, "generationHistoryRuntimeRouterLock", new Object()); + doCallRealMethod().when(engine).attachGenerationHistoryRuntimeRouter(router); + engine.attachGenerationHistoryRuntimeRouter(router); + } + private ShutdownFixture() throws Exception { EngineBackgroundTasks background = mock(EngineBackgroundTasks.class); when(background.drainBackgroundTasks(anyString())) diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java index 6f185ee00..c4f3c86da 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java @@ -18,6 +18,8 @@ import art.arcane.iris.engine.framework.SeedManager; import art.arcane.iris.engine.framework.IrisEngineMode; import art.arcane.iris.engine.framework.NativeStructureVolumeMemo; import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter; +import art.arcane.iris.engine.history.GenerationHistory; +import art.arcane.iris.engine.history.GenerationAdmission; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisBiome; @@ -515,9 +517,7 @@ public class IrisEngineGenerationRuntimeScopeTest { public void attachedHistoryRouterIsClearedWhenEngineShutdownBegins() throws Exception { RuntimeFixture active = runtime(1, 1D, 1D, 1D); IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); - GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); - when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); - when(router.engine()).thenReturn(engine); + GenerationHistoryRuntimeRouter router = historyRouter(engine); engine.attachGenerationHistoryRuntimeRouter(router); assertTrue(engine.beginShutdown()); @@ -531,9 +531,7 @@ public class IrisEngineGenerationRuntimeScopeTest { public void attachedHistoryRejectsGenerationRuntimeHotload() throws Exception { RuntimeFixture active = runtime(1, 1D, 1D, 1D); IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); - GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); - when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); - when(router.engine()).thenReturn(engine); + GenerationHistoryRuntimeRouter router = historyRouter(engine); engine.attachGenerationHistoryRuntimeRouter(router); IllegalStateException failure = assertThrows(IllegalStateException.class, engine::hotloadComplex); @@ -548,9 +546,7 @@ public class IrisEngineGenerationRuntimeScopeTest { RuntimeFixture active = runtime(1, 1D, 1D, 1D); IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); assertNull(engine.openGenerationHistoryCoordinateScope(0, 0)); - GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); - when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); - when(router.engine()).thenReturn(engine); + GenerationHistoryRuntimeRouter router = historyRouter(engine); engine.attachGenerationHistoryRuntimeRouter(router); engine.detachGenerationHistoryRuntimeRouter(router); @@ -710,6 +706,16 @@ public class IrisEngineGenerationRuntimeScopeTest { assertSame(active.dimension, engine.getDimension()); } + private static GenerationHistoryRuntimeRouter historyRouter(IrisEngine engine) { + GenerationHistory history = mock(GenerationHistory.class); + when(history.retainRuntime()).thenReturn(mock(GenerationAdmission.RuntimeLease.class)); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + when(router.biomes()).thenReturn(mock(SavedBiomeRuntime.class)); + when(router.engine()).thenReturn(engine); + when(router.history()).thenReturn(history); + return router; + } + private static IrisEngine engine( GenerationRuntime generationRuntime, EngineEffects effects, diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationAdmissionTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationAdmissionTest.java index 86ad178ea..d01046953 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationAdmissionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationAdmissionTest.java @@ -4,6 +4,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import java.nio.file.Path; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.FutureTask; @@ -103,6 +104,99 @@ public class GenerationAdmissionTest { assertThrows(IllegalStateException.class, admission::beginStartupCutover); } + @Test + public void lastRuntimeReleaseAllowsStartupOnAFreshAdmissionOnly() { + Path root = temporaryFolder.getRoot().toPath(); + GenerationAdmission admission = new GenerationAdmission(root); + GenerationAdmission stale = new GenerationAdmission(root); + GenerationAdmission.RuntimeLease runtime = admission.retainRuntime(); + try (GenerationAdmission.StageLease ignored = admission.enterStage()) { + } + runtime.close(); + GenerationAdmission reopened = new GenerationAdmission(root); + try (GenerationAdmission.CutoverLease ignored = reopened.beginStartupCutover()) { + } + assertThrows(IllegalStateException.class, stale::enterStage); + assertThrows(IllegalStateException.class, stale::beginCutover); + assertThrows(IllegalStateException.class, stale::retainRuntime); + runtime.close(); + try (GenerationAdmission.StageLease ignored = reopened.enterStage()) { + } + } + + @Test + public void everyRuntimeMustReleaseBeforeAdmissionCanRetire() { + Path root = temporaryFolder.getRoot().toPath(); + GenerationAdmission first = new GenerationAdmission(root); + GenerationAdmission second = new GenerationAdmission(root); + GenerationAdmission.RuntimeLease firstRuntime = first.retainRuntime(); + GenerationAdmission.RuntimeLease secondRuntime = second.retainRuntime(); + try (GenerationAdmission.StageLease ignored = first.enterStage()) { + } + firstRuntime.close(); + assertThrows(IllegalStateException.class, new GenerationAdmission(root)::beginStartupCutover); + try (GenerationAdmission.StageLease ignored = second.enterStage()) { + } + secondRuntime.close(); + try (GenerationAdmission.CutoverLease ignored = new GenerationAdmission(root).beginStartupCutover()) { + } + } + + @Test + public void failedRetirementRetainsTheLeaseUntilActiveStagesFinish() { + GenerationAdmission admission = new GenerationAdmission(temporaryFolder.getRoot().toPath()); + GenerationAdmission.RuntimeLease runtime = admission.retainRuntime(); + GenerationAdmission.StageLease stage = admission.enterStage(); + assertThrows(IllegalStateException.class, runtime::close); + assertThrows(IllegalStateException.class, admission::beginStartupCutover); + stage.close(); + runtime.close(); + assertThrows(IllegalStateException.class, admission::enterStage); + } + + @Test + public void failedRetirementRetainsTheLeaseUntilActiveCutoversFinish() { + GenerationAdmission admission = new GenerationAdmission(temporaryFolder.getRoot().toPath()); + GenerationAdmission.RuntimeLease runtime = admission.retainRuntime(); + GenerationAdmission.CutoverLease cutover = admission.beginCutover(); + assertThrows(IllegalStateException.class, runtime::close); + cutover.close(); + runtime.close(); + assertThrows(IllegalStateException.class, admission::beginCutover); + } + + @Test + public void waitingCutoversKeepAdmissionOwnedUntilTheirWorkCompletes() throws Exception { + GenerationAdmission admission = new GenerationAdmission(temporaryFolder.getRoot().toPath()); + GenerationAdmission.RuntimeLease runtime = admission.retainRuntime(); + GenerationAdmission.StageLease stage = admission.enterStage(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + FutureTask cutover = new FutureTask<>(() -> { + try (GenerationAdmission.CutoverLease ignored = admission.beginCutover()) { + entered.countDown(); + await(release); + } + }, null); + Thread thread = Thread.ofPlatform().daemon().unstarted(cutover); + try { + thread.start(); + awaitWaiting(thread); + assertThrows(IllegalStateException.class, runtime::close); + stage.close(); + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, runtime::close); + release.countDown(); + cutover.get(5L, TimeUnit.SECONDS); + runtime.close(); + assertThrows(IllegalStateException.class, admission::beginCutover); + } finally { + stage.close(); + release.countDown(); + thread.join(5_000L); + } + } + private static void awaitWaiting(Thread thread) throws InterruptedException { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L); while (thread.getState() != Thread.State.WAITING && System.nanoTime() < deadline) { diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java new file mode 100644 index 000000000..4bb70b4d4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java @@ -0,0 +1,107 @@ +package art.arcane.iris.engine.platform; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.io.ReactiveFolder; +import org.bukkit.generator.BlockPopulator; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class BukkitChunkGeneratorCloseTest { + @Test + public void asynchronousCloseFailureCanRetryWithoutReopeningGeneration() throws Exception { + CloseFixture fixture = new CloseFixture(); + IllegalStateException failure = new IllegalStateException("Planner remains active"); + doThrow(failure).doNothing().when(fixture.engine).close(); + CompletableFuture firstOperation = fixture.queueOperation(); + CompletableFuture firstClose = fixture.generator.closeAsync(); + assertSame(firstClose, fixture.generator.closeAsync()); + firstOperation.completeExceptionally(assertThrows(IllegalStateException.class, fixture.operation.get()::run)); + assertTrue(firstClose.isCompletedExceptionally()); + assertTrue(fixture.generator.isClosing()); + + CompletableFuture secondOperation = fixture.queueOperation(); + CompletableFuture secondClose = fixture.generator.closeAsync(); + assertNotSame(firstClose, secondClose); + fixture.operation.get().run(); + secondOperation.complete(null); + secondClose.join(); + assertTrue(fixture.generator.isClosing()); + assertSame(secondClose, fixture.generator.closeAsync()); + verify(fixture.engine, times(2)).close(); + } + + @Test + public void failedRetryDispatchKeepsPartiallyClosedGenerationSealed() throws Exception { + CloseFixture fixture = new CloseFixture(); + CompletableFuture operation = fixture.queueOperation(); + CompletableFuture firstClose = fixture.generator.closeAsync(); + operation.completeExceptionally(new IllegalStateException("Planner remains active")); + assertTrue(firstClose.isCompletedExceptionally()); + doThrow(new IllegalStateException("Scheduler unavailable")).when(fixture.generator) + .withExclusiveControlFuture(any(Runnable.class)); + + CompletableFuture retry = fixture.generator.closeAsync(); + assertNotSame(firstClose, retry); + assertTrue(retry.isCompletedExceptionally()); + assertTrue(fixture.generator.isClosing()); + } + + @Test + public void initialDispatchFailureLeavesTheUnchangedGeneratorAvailable() throws Exception { + CloseFixture fixture = new CloseFixture(); + doThrow(new IllegalStateException("Scheduler unavailable")).when(fixture.generator) + .withExclusiveControlFuture(any(Runnable.class)); + + CompletableFuture failedClose = fixture.generator.closeAsync(); + assertTrue(failedClose.isCompletedExceptionally()); + assertFalse(fixture.generator.isClosing()); + } + + private static void setField(BukkitChunkGenerator generator, String name, Object value) throws Exception { + Field field = BukkitChunkGenerator.class.getDeclaredField(name); + field.setAccessible(true); + field.set(generator, value); + } + + private static final class CloseFixture { + private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + private final Engine engine = mock(Engine.class); + private final AtomicReference operation = new AtomicReference<>(); + private final ConcurrentLinkedQueue> operations = new ConcurrentLinkedQueue<>(); + + private CloseFixture() throws Exception { + setField(generator, "closeFuture", new AtomicReference>()); + setField(generator, "engine", engine); + setField(generator, "folder", mock(ReactiveFolder.class)); + setField(generator, "populators", new KList()); + doAnswer(invocation -> { + operation.set(invocation.getArgument(0, Runnable.class)); + return operations.remove(); + }).when(generator).withExclusiveControlFuture(any(Runnable.class)); + } + + private CompletableFuture queueOperation() { + CompletableFuture future = new CompletableFuture<>(); + operations.add(future); + return future; + } + } +}