Restored world reload lifecycle

- Generation admission retires after complete runtime cleanup
- Fresh world loads can activate pending compatible updates
- Failed generator closes remain sealed and retryable
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-06 04:47:26 -04:00
commit f916c81bc0
10 files changed
+548 -109

No files matched your search

@@ -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);
}
}
}
}
@@ -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<GenerationAdmission.RuntimeLease> generationAdmissions = new ArrayList<>(1);
private final Set<RuntimeAssembly> incompleteAssemblies = ConcurrentHashMap.newKeySet();
private final Set<EngineRuntime> 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<GenerationAdmission.RuntimeLease> 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) {
@@ -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;
}
@@ -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 {
@@ -64,6 +64,10 @@ public final class GenerationHistory {
validateReferencedState();
}
public GenerationAdmission.RuntimeLease retainRuntime() {
return admission.retainRuntime();
}
public SavedBiomeStore savedBiomes() {
return savedBiomes;
}
@@ -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<Void> 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<Void> 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);
}
});
@@ -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<NativeStructureOwnershipStore> 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<NativeStructureOwnershipStore> 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<NativeStructureOwnershipStore> 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<NativeStructureOwnershipStore> 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<NativeStructureOwnershipStore> 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()))
@@ -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,
@@ -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<Void> 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) {
@@ -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<Void> firstOperation = fixture.queueOperation();
CompletableFuture<Void> 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<Void> secondOperation = fixture.queueOperation();
CompletableFuture<Void> 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<Void> operation = fixture.queueOperation();
CompletableFuture<Void> 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<Void> 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<Void> 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<Runnable> operation = new AtomicReference<>();
private final ConcurrentLinkedQueue<CompletableFuture<Void>> operations = new ConcurrentLinkedQueue<>();
private CloseFixture() throws Exception {
setField(generator, "closeFuture", new AtomicReference<CompletableFuture<Void>>());
setField(generator, "engine", engine);
setField(generator, "folder", mock(ReactiveFolder.class));
setField(generator, "populators", new KList<BlockPopulator>());
doAnswer(invocation -> {
operation.set(invocation.getArgument(0, Runnable.class));
return operations.remove();
}).when(generator).withExclusiveControlFuture(any(Runnable.class));
}
private CompletableFuture<Void> queueOperation() {
CompletableFuture<Void> future = new CompletableFuture<>();
operations.add(future);
return future;
}
}
}