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
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-06 04:46:38 -04:00
1 parent 62f6a32362
commit 2b124a2a25
10 files changed
+548 -109

No files matched your search

@@ -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);
}
});