diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index f47c8f235..647aafd92 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -73,6 +73,7 @@ import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.GenerationSessionException; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy; @@ -517,6 +518,12 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat } private void recordBindFailure(NativeWorld level, Throwable error) { + // A runtime that is stopping says nothing about the world: fail this call, let the next bind try again. + if (GenerationFailures.isShutdownFailure(error)) { + ModdedIrisLog.warn("Iris bind of {} ({}) stopped with its runtime: {}", level.name(), dimensionKey, + error.toString()); + return; + } engineBinding.fail(error); for (String line : bindFailureNotice(level.name(), dimensionKey, error)) { ModdedIrisLog.error(line); @@ -588,21 +595,11 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat } } - Engine engineOrNull() { - requireBindingAllowed(); - Engine cached = engine; - requireCompletedShutdown(cached); - if (cached != null && !cached.isClosed()) { - return cached; - } - try { - return engine(); - } catch (Throwable ignored) { - return null; - } - } - - Engine structureEngineOrNull() { + /** + * Null only while no level uses this generator (world creation screens, codec validation). A bound level whose + * engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator. + */ + Engine boundEngineOrNull() { requireBindingAllowed(); engineBinding.throwIfFailed(dimensionKey); Engine cached = engine; @@ -1007,7 +1004,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat @Override public NativeModdedBiomePolicy biomePolicy(NativeBiomeSourceAccess source) { ModdedBiomePolicy policy = new ModdedBiomePolicy<>(source); - policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(this::structureEngineOrNull, this::awaitStructureEngine, + policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(this::boundEngineOrNull, this::awaitStructureEngine, this::allowsGenerationHistoryBypass, this::configuredStructureBiomeKeys, IrisModdedChunkGenerator::retainedBiomeKeys, this::visibleBiomeSeed)); return policy; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java index 83d69e3c3..7cba27969 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java @@ -266,7 +266,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { @Override public H getNoiseBiome(int quartX, int quartY, int quartZ, S sampler) { - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return access.serializedNoise(quartX, quartY, quartZ, sampler); } @@ -288,7 +288,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { @Override public NativeBiomeSourcePolicy.BiomeLocation findClosestBiome3d(ClosestQuery query) { - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return query.unboundFallback().get(); } @@ -355,7 +355,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { } public H getVisibleNoiseBiome(int quartX, int quartY, int quartZ, S sampler) { - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return access.serializedNoise(quartX, quartY, quartZ, sampler); } @@ -411,7 +411,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { } public H getVisibleSurfaceBiome(int blockX, int blockZ) { - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return null; } @@ -420,15 +420,15 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { try (historyScope) { GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_surface_spawn_biome"); if (lease == null) { - return null; + throw new IllegalStateException("Iris surface spawn biome lookup was rejected during an engine transition"); } try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { if (!isReady(engine)) { - return null; + throw new IllegalStateException("Iris surface spawn biome lookup has no active engine runtime"); } NativeBiomeSourceAccess.RegistryView registry = biomeRegistry(); if (registry == null) { - return null; + throw new IllegalStateException("Iris surface spawn biome lookup has no biome registry"); } int quartX = ((blockX) >> 2); int quartZ = ((blockZ) >> 2); @@ -463,7 +463,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { } public boolean isStructureReachable(Iterable biomes) { - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return isStructureReachable(biomes, possibleStructureBiomeKeys()); } @@ -494,7 +494,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { int radius = query.radius(); S sampler = query.sampler(); int minQuartY = ((y - radius) >> 2); - Engine engine = engineOrNull(); + Engine engine = boundEngineOrNull(); if (engine == null) { return query.fallback().get(); } @@ -874,7 +874,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { return engine != null && !engine.isClosed() && engine.getComplex() != null; } - private Engine engineOrNull() { + private Engine boundEngineOrNull() { RuntimeCallbacks current = runtime; return current == null ? null : current.currentEngine().get(); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java index b4a162b54..0c73b4fed 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java @@ -25,6 +25,7 @@ import art.arcane.volmlib.nativelib.terrain.feature.NativeFeatureTable; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedImportedFeatures; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationClosedException; import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.structure.nativegen.NativeFeatureGenerationPolicy; @@ -96,10 +97,12 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe * the per-step feature lists and {@code BiomeFilter}'s hasFeature gate both see real features for a biome * whose datapack JSON declares none by design. Real registry biomes pass straight through. * - *

With {@code importedFeatures} disabled there is no table and this is vanilla's default getter. + *

With {@code importedFeatures} disabled there is no table and this is vanilla's default getter. So is an + * unbound generator, which only world creation and codec validation ask. A bound generator whose engine is + * refused or stopping throws: BiomeFilter would otherwise drop every imported feature of a custom biome. */ public NativeFeatureTable currentTable() { - Engine engine = generator == null ? null : generator.engineOrNull(); + Engine engine = generator == null ? null : generator.boundEngineOrNull(); return engine == null ? null : featureTables.get(engine.getCacheID()); } @@ -112,8 +115,15 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe * the build itself is serialized because {@code applyBiomeDecoration} calls this from every worldgen * thread, and two threads that both found the stage unprepared would each run {@code FeatureSorter}, whose * cycle detection is the expensive part. Waiting here is safe: this caller holds no generator monitor. + * A sealed runtime keeps the table it already settled but cannot build one, and decorating without it + * would persist a chunk with no imported features, so it fails the chunk instead. */ public void prepare(Engine engine) { + if ((engine.isClosed() || engine.isClosing()) + && !settled(engine.getCacheID(), biomeSource.packGeneration())) { + throw new GenerationClosedException("Iris imported features cannot prepare runtime " + + engine.getCacheID() + " while its engine is closing."); + } prepare(engine, true); } @@ -128,7 +138,7 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe } private void prepare(Engine engine, boolean waitForBuild) { - if (engine == null || engine.isClosed() || engine.isClosing()) { + if (engine.isClosed() || engine.isClosing()) { return; } int runtimeIdentity = engine.getCacheID(); @@ -204,17 +214,24 @@ final class ModdedImportedFeatureStage implements NativeModdedGeneratorPolicy.Fe inertGenerations.put(runtimeIdentity, generation); } + /** + * Null only for a runtime whose features are settled off. A table retired or superseded since this chunk's + * prepare fails the chunk: skipping the pass would persist it without imported features. + */ public NativeFeatureTable placementTable(Engine engine) { int runtimeIdentity = engine.getCacheID(); + long generation = biomeSource.packGeneration(); NativeFeatureTable table = featureTables.get(runtimeIdentity); - if (table == null) { + if (table == null && Long.valueOf(generation).equals(inertGenerations.get(runtimeIdentity))) { WorldCheckFeaturePlacement.recordFeaturesOff(); return null; } - if (table.generation() != biomeSource.packGeneration()) { - featureTables.remove(runtimeIdentity, table); - inertGenerations.remove(runtimeIdentity); - return null; + if (table == null || table.generation() != generation) { + if (table != null) { + featureTables.remove(runtimeIdentity, table); + } + throw new GenerationClosedException("Iris imported feature table for runtime " + runtimeIdentity + + " was retired before decoration."); } return generator == null ? null : table; } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java index 1a4ca2ccf..40b207410 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBindFailureTest.java @@ -4,6 +4,7 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedChunkGenerator; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.mantle.MantleClosedException; import net.minecraft.SharedConstants; import net.minecraft.server.Bootstrap; import net.minecraft.server.level.ServerChunkCache; @@ -16,14 +17,22 @@ import org.mockito.MockedStatic; import java.io.IOException; import java.lang.reflect.Field; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +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.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @@ -63,6 +72,62 @@ public class ModdedBindFailureTest { } } + /** + * A bind that fails because the runtime is stopping (level unload, quit to title, server stop) says nothing about + * the world: it fails the chunk that asked, but the next bind tries again and no refusal is announced. + */ + @Test + public void aShutdownFailureDuringBindIsNeitherRememberedNorAnnouncedAsARefusal() throws Exception { + IrisModdedChunkGenerator generator = generator("overworld:overworld"); + NativeWorld level = level(generator, "minecraft:overworld"); + field("boundLevel").set(generator, level); + NativeModdedServer server = mock(NativeModdedServer.class); + when(server.generateStructures()).thenReturn(true); + MantleClosedException closed = new MantleClosedException("Tectonic Plate is closed!"); + + try (MockedStatic servers = mockStatic(NativeModdedServer.class); + MockedStatic engines = mockStatic(ModdedWorldEngines.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server); + engines.when(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any())).thenThrow(closed); + + assertSame(closed, assertThrows(MantleClosedException.class, generator::engine)); + assertSame(closed, assertThrows(MantleClosedException.class, generator::engine)); + + engines.verify(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()), times(2)); + log.verify(() -> ModdedIrisLog.error(anyString()), never()); + } + } + + /** + * Unloading a level resets its binding, so a refusal recorded in one load never carries into the next load of the + * same generator (a runtime dimension removal that rolls back, or a level loaded again in the same JVM). + */ + @Test + public void aRecordedRefusalDoesNotOutliveTheUnloadOfItsLevel() throws Exception { + IrisModdedChunkGenerator generator = generator("overworld:overworld"); + NativeWorld level = level(generator, "minecraft:overworld"); + doNothing().when((NativeModdedChunkGenerator) field("nativeGenerator").get(generator)).clearCaches(); + field("importedFeatures").set(generator, mock(ModdedImportedFeatureStage.class)); + field("announced").set(generator, new AtomicBoolean()); + NativeModdedServer server = mock(NativeModdedServer.class); + when(server.generateStructures()).thenReturn(false); + + try (MockedStatic servers = mockStatic(NativeModdedServer.class); + MockedStatic engines = mockStatic(ModdedWorldEngines.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server); + + IllegalStateException first = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); + generator.unbindEngine(level); + IllegalStateException second = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); + + assertNotSame(first, second.getCause()); + servers.verify(() -> NativeModdedServer.forWorld(level), times(2)); + engines.verify(() -> ModdedWorldEngines.evictOrThrow(level)); + } + } + @Test public void bindFailureNoticeNamesTheLevelTheCauseAndThatNothingGenerates() { String notice = String.join("\n", IrisModdedChunkGenerator.bindFailureNotice( diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java index 644e24040..4511d8a3a 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.modded; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationSessionException; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.volmlib.nativelib.terrain.NativeBiomeSourceAccess; @@ -84,6 +85,29 @@ public class ModdedBiomePolicyTest { assertSame(forest, policy.requiredStructureBiome(6000, -30, -6000, null)); } + /** + * Stacked initial mob spawning asks for the visible surface biome. A null answer made it spawn from the chunk's + * top biome instead, so a sealed or unready engine has to fail the chunk. + */ + @Test + public void surfaceSpawnBiomeRefusesAnEngineInTransitionInsteadOfAnsweringNothing() throws Exception { + Engine sealed = mock(Engine.class); + when(sealed.isClosing()).thenReturn(true); + when(sealed.acquireGenerationLease(anyString())).thenThrow(new GenerationSessionException("sealed", true)); + Engine unready = mock(Engine.class); + when(unready.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop()); + + assertThrows(IllegalStateException.class, () -> boundTo(sealed).getVisibleSurfaceBiome(8, 8)); + assertThrows(IllegalStateException.class, () -> boundTo(unready).getVisibleSurfaceBiome(8, 8)); + } + + private static ModdedBiomePolicy boundTo(Engine engine) { + ModdedBiomePolicy policy = new ModdedBiomePolicy<>(new Access(new ArrayList<>())); + policy.bind(new ModdedBiomePolicy.RuntimeCallbacks(() -> engine, () -> engine, bound -> true, + Set::of, bound -> Set.of(), () -> 0L)); + return policy; + } + private static ModdedBiomePolicy policy(List entries, AtomicReference> configured) { ModdedBiomePolicy policy = new ModdedBiomePolicy<>(new Access(entries)); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java index 5bb349ddf..8b0759862 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedImportedFeatureStageTest.java @@ -3,6 +3,7 @@ package art.arcane.iris.modded; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeFeatureBiomeSource; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationClosedException; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl; import org.junit.Test; @@ -11,6 +12,9 @@ import net.minecraft.SharedConstants; import net.minecraft.server.Bootstrap; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -56,17 +60,78 @@ public class ModdedImportedFeatureStageTest { } @Test - public void closingRuntimeDoesNotBuildFeatureTables() { + public void aSealedRuntimeWithoutASettledTableRefusesDecorationInsteadOfSkippingFeatures() { NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class); + when(source.packGeneration()).thenReturn(1L); Engine engine = mock(Engine.class); + when(engine.getCacheID()).thenReturn(7); when(engine.isClosing()).thenReturn(true); ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source); - stage.prepare(engine); stage.prepareWithoutWaiting(engine); + assertThrows(GenerationClosedException.class, () -> stage.prepare(engine)); - verify(source, never()).packGeneration(); verify(engine, never()).getDimension(); assertFalse(stage.active()); } + + @Test + public void aSealedRuntimeKeepsDecoratingWithTheTableItAlreadySettled() { + NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class); + when(source.packGeneration()).thenReturn(1L); + Engine engine = disabledFeatures(7); + ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source); + stage.bind(mock(IrisModdedChunkGenerator.class)); + stage.prepare(engine); + + when(engine.isClosing()).thenReturn(true); + stage.prepare(engine); + + assertNull(stage.placementTable(engine)); + } + + @Test + public void aTableRetiredBetweenPrepareAndPlacementFailsTheChunk() { + NativeFeatureBiomeSource source = mock(NativeFeatureBiomeSource.class); + when(source.packGeneration()).thenReturn(1L); + Engine engine = disabledFeatures(7); + ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(source); + stage.bind(mock(IrisModdedChunkGenerator.class)); + + stage.prepare(engine); + stage.evictRuntime(7); + assertThrows(GenerationClosedException.class, () -> stage.placementTable(engine)); + + stage.prepare(engine); + when(source.packGeneration()).thenReturn(2L); + assertThrows(GenerationClosedException.class, () -> stage.placementTable(engine)); + } + + @Test + public void anUnboundGeneratorAnswersVanillaGenerationSettings() { + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class); + when(generator.boundEngineOrNull()).thenReturn(null); + ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(mock(NativeFeatureBiomeSource.class)); + stage.bind(generator); + + assertNull(stage.currentTable()); + } + + @Test + public void aFailedBindFailsTheGenerationSettingsInsteadOfDroppingImportedFeatures() { + IllegalStateException refused = new IllegalStateException("Iris generator 'overworld:overworld' failed to bind"); + IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class); + when(generator.boundEngineOrNull()).thenThrow(refused); + ModdedImportedFeatureStage stage = new ModdedImportedFeatureStage(mock(NativeFeatureBiomeSource.class)); + stage.bind(generator); + + assertSame(refused, assertThrows(IllegalStateException.class, stage::currentTable)); + } + + private static Engine disabledFeatures(int runtimeIdentity) { + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(new IrisDimension().setImportedFeatures(new IrisImportedFeatureControl())); + when(engine.getCacheID()).thenReturn(runtimeIdentity); + return engine; + } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java index 17564f94c..0ecab8a9c 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationFailures.java @@ -8,6 +8,7 @@ import java.nio.channels.ClosedChannelException; import java.util.concurrent.CancellationException; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeoutException; +import java.util.function.Predicate; /** * Separates failures of the engine, its mantle, generation history, executors or the JVM from failures of one @@ -22,22 +23,32 @@ public final class GenerationFailures { } public static boolean isEngineFailure(Throwable failure) { - if (Thread.currentThread().isInterrupted()) { - return true; - } + return isShutdownFailure(failure) || hasCause(failure, (Throwable cause) -> cause instanceof Error + || cause instanceof WrongEngineBroException + || cause instanceof SavedBiomeUnavailableException unavailable && unavailable.isLoading() + || cause instanceof TimeoutException); + } + + /** + * The engine failures that only mean a runtime, pool or thread is stopping. They say nothing about the world, so + * nothing may remember them against it once the next runtime is up. + */ + public static boolean isShutdownFailure(Throwable failure) { + return Thread.currentThread().isInterrupted() || hasCause(failure, (Throwable cause) -> + cause instanceof GenerationClosedException + || cause instanceof MantleClosedException + || cause instanceof GenerationSessionException session && session.isExpectedTeardown() + || cause instanceof InterruptedException + || cause instanceof InterruptedIOException + || cause instanceof ClosedChannelException + || cause instanceof RejectedExecutionException + || cause instanceof CancellationException); + } + + private static boolean hasCause(Throwable failure, Predicate match) { Throwable current = failure; for (int depth = 0; current != null && depth < MAXIMUM_CAUSE_DEPTH; depth++, current = current.getCause()) { - if (current instanceof Error - || current instanceof GenerationClosedException - || current instanceof MantleClosedException - || current instanceof WrongEngineBroException - || current instanceof SavedBiomeUnavailableException unavailable && unavailable.isLoading() - || current instanceof InterruptedException - || current instanceof InterruptedIOException - || current instanceof ClosedChannelException - || current instanceof RejectedExecutionException - || current instanceof CancellationException - || current instanceof TimeoutException) { + if (match.test(current)) { return true; } } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java index 375096bfc..14dd8d479 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/GenerationFailuresTest.java @@ -4,6 +4,7 @@ import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.volmlib.util.mantle.MantleClosedException; import org.junit.Test; +import java.io.IOException; import java.nio.channels.ClosedByInterruptException; import java.util.concurrent.CompletionException; import java.util.concurrent.RejectedExecutionException; @@ -52,6 +53,33 @@ public class GenerationFailuresTest { } } + @Test + public void shutdownFailuresAreTheLifecycleSubsetOfEngineFailures() { + assertTrue(GenerationFailures.isShutdownFailure(new GenerationClosedException("router closed"))); + assertTrue(GenerationFailures.isShutdownFailure(new MantleClosedException("Tectonic Plate is closed!"))); + assertTrue(GenerationFailures.isShutdownFailure(new RejectedExecutionException("pool shut down"))); + assertTrue(GenerationFailures.isShutdownFailure(new ClosedByInterruptException())); + assertTrue(GenerationFailures.isShutdownFailure(new GenerationSessionException("sealed", true))); + assertTrue(GenerationFailures.isShutdownFailure(new IllegalStateException("bind failed", + new CompletionException(new InterruptedException())))); + + assertFalse(GenerationFailures.isShutdownFailure(new OutOfMemoryError())); + assertFalse(GenerationFailures.isShutdownFailure(new NoClassDefFoundError("art/arcane/Missing"))); + assertFalse(GenerationFailures.isShutdownFailure(new GenerationSessionException("wrong engine"))); + assertFalse(GenerationFailures.isShutdownFailure(new IllegalStateException( + "Iris generation history is unusable", new IOException("registry definition changed")))); + } + + @Test + public void anyFailureOnAnInterruptedThreadIsAShutdownFailure() { + Thread.currentThread().interrupt(); + try { + assertTrue(GenerationFailures.isShutdownFailure(new IllegalStateException("history unusable"))); + } finally { + Thread.interrupted(); + } + } + @Test public void rethrowPropagatesEngineFailuresAndReturnsForContentFailures() { GenerationClosedException closed = new GenerationClosedException("router closed");