From 03b0f6e8010b670f74db4cb12929339b6befbf06 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 6 Sep 2026 05:08:13 -0400 Subject: [PATCH] Fixed spawn prefetch startup - Spawn hydrology checks existing chunks asynchronously without generating them - Prefetch admission rejects closed or replaced generation runtimes - Studio entry retains tracked hydrology preparation - Admitted planning failures retain their stacktraces during shutdown - Regression coverage checks startup admission and delayed completion --- .../engine/platform/BukkitChunkGenerator.java | 91 ++++--- ...tChunkGeneratorInitializationModeTest.java | 27 +- ...ukkitChunkGeneratorSpawnHydrologyTest.java | 237 ++++++++++++++++++ 3 files changed, 320 insertions(+), 35 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorSpawnHydrologyTest.java 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 01fa4d8de..36f45bad0 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 @@ -37,6 +37,7 @@ import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.data.chunk.TerrainChunk; @@ -52,6 +53,7 @@ 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; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.IrisDimensionContractException; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; @@ -101,6 +103,7 @@ import java.util.Locale; import java.util.Objects; import java.util.Random; import java.util.UUID; +import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; @@ -258,41 +261,73 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } /** Starts nearest-first hydrology planning around a new world's initial spawn. */ - private void prefetchSpawnHydrology(Engine engine, World world) { + private CompletableFuture prefetchSpawnHydrology(Engine engine, World world) { if (usesFlatStudioTerrain()) { - return; + return CompletableFuture.completedFuture(null); } - if (engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) { - return; + IrisComplex complex = engine.getComplex(); + IrisHydrologyRuntime hydrology = complex == null ? null : complex.getHydrologyRuntime(); + if (hydrology == null) { + return CompletableFuture.completedFuture(null); } Location spawn = getInitialSpawnLocation(world); - int spawnX = spawn.getBlockX(); - int spawnZ = spawn.getBlockZ(); - if (world.isChunkGenerated(spawnX >> 4, spawnZ >> 4)) { - return; - } - if (studio && engine.getDimension().getStudioMode() == StudioMode.NORMAL - && engine instanceof IrisEngine irisEngine) { - irisEngine.startStudioEntryHydrology(spawnX, spawnZ); - return; - } - int reach = Math.max(16, hydrologyTileSize() / 2); - engine.getComplex().getHydrologyRuntime().prefetchArea( - spawnX - reach, - spawnZ - reach, - spawnX + reach - 1, - spawnZ + reach - 1, - spawnX, - spawnZ - ); + return requestChunkAsync(world, spawn.getBlockX() >> 4, spawn.getBlockZ() >> 4, false) + .exceptionally(failure -> { + if (closing || this.engine != engine || engine.isClosing() || engine.isClosed()) { + return null; + } + throw new CompletionException(failure); + }) + .thenAcceptAsync(chunk -> { + if (chunk == null) { + startSpawnHydrology(engine, hydrology, spawn); + } + }) + .whenComplete((ignored, failure) -> { + if (failure != null) { + IrisLogging.reportError("Failed to prefetch spawn hydrology for " + world.getName(), failure); + } + }); } - private int hydrologyTileSize() { - Engine activeEngine = engine; - if (activeEngine == null || activeEngine.getComplex() == null || activeEngine.getComplex().getHydrologyRuntime() == null) { - return 0; + private void startSpawnHydrology(Engine expectedEngine, IrisHydrologyRuntime hydrology, Location spawn) { + if (closing) { + return; + } + GenerationStagePermit permit; + try { + permit = acquireGenerationStage("spawn_hydrology_prefetch"); + } catch (IllegalStateException failure) { + if (closing && failure.getCause() == null) { + return; + } + throw failure; + } + try (permit) { + if (engine != expectedEngine || expectedEngine.isClosing() || expectedEngine.isClosed() || usesFlatStudioTerrain()) { + return; + } + IrisComplex complex = expectedEngine.getComplex(); + if (complex == null || complex.getHydrologyRuntime() != hydrology) { + return; + } + int spawnX = spawn.getBlockX(); + int spawnZ = spawn.getBlockZ(); + if (studio && expectedEngine.getDimension().getStudioMode() == StudioMode.NORMAL + && expectedEngine instanceof IrisEngine irisEngine) { + irisEngine.startStudioEntryHydrology(spawnX, spawnZ); + return; + } + int reach = Math.max(16, hydrology.settings().routing().tileSize() / 2); + hydrology.prefetchArea( + spawnX - reach, + spawnZ - reach, + spawnX + reach - 1, + spawnZ + reach - 1, + spawnX, + spawnZ + ); } - return activeEngine.getComplex().getHydrologyRuntime().settings().routing().tileSize(); } private void updateSpawnLocation(World world) { diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java index e4d28f0db..53e97123a 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java @@ -3,7 +3,9 @@ package art.arcane.iris.engine.platform; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import org.bukkit.Chunk; import org.bukkit.World; import art.arcane.iris.engine.object.StudioMode; import art.arcane.iris.engine.object.IrisDimension; @@ -14,11 +16,14 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -35,23 +40,31 @@ public class BukkitChunkGeneratorInitializationModeTest { Field studio = BukkitChunkGenerator.class.getDeclaredField("studio"); studio.setAccessible(true); studio.setBoolean(generator, true); + Field loadLock = BukkitChunkGenerator.class.getDeclaredField("loadLock"); + loadLock.setAccessible(true); + loadLock.set(generator, new BukkitChunkGenerator.GenerationStageGate(2, generator::isClosing)); doReturn(false).when(generator).usesFlatStudioTerrain(); World world = mock(World.class); when(world.getMinHeight()).thenReturn(-64); when(world.getMaxHeight()).thenReturn(320); + when(world.getChunkAtAsync(0, 0, false)).thenReturn(CompletableFuture.completedFuture(null)); IrisEngine engine = mock(IrisEngine.class); + generator.setEngine(engine); IrisDimension dimension = mock(IrisDimension.class); when(engine.getDimension()).thenReturn(dimension); when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL); IrisComplex complex = mock(IrisComplex.class); IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class); + HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class, RETURNS_DEEP_STUBS); + when(settings.routing().tileSize()).thenReturn(512); + when(hydrology.settings()).thenReturn(settings); when(engine.getComplex()).thenReturn(complex); when(complex.getHydrologyRuntime()).thenReturn(hydrology); Method prefetch = BukkitChunkGenerator.class.getDeclaredMethod( "prefetchSpawnHydrology", Engine.class, World.class); prefetch.setAccessible(true); - prefetch.invoke(generator, engine, world); + ((CompletableFuture) prefetch.invoke(generator, engine, world)).get(5L, TimeUnit.SECONDS); verify(engine).startStudioEntryHydrology(0, 0); verifyNoInteractions(hydrology); @@ -60,21 +73,21 @@ public class BukkitChunkGeneratorInitializationModeTest { assertEquals(0.5D, generator.getInitialSpawnLocation(world).getZ(), 0D); when(dimension.getStudioMode()).thenReturn(StudioMode.BIOME_BUFFET_1x1); - prefetch.invoke(generator, engine, world); + ((CompletableFuture) prefetch.invoke(generator, engine, world)).get(5L, TimeUnit.SECONDS); verify(engine, times(1)).startStudioEntryHydrology(0, 0); verify(hydrology).prefetchArea(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL); IrisEngine authoring = mock(IrisEngine.class); doReturn(true).when(generator).usesFlatStudioTerrain(); - prefetch.invoke(generator, authoring, world); + ((CompletableFuture) prefetch.invoke(generator, authoring, world)).get(5L, TimeUnit.SECONDS); verifyNoInteractions(authoring); IrisEngine generated = mock(IrisEngine.class); when(generated.getComplex()).thenReturn(complex); doReturn(false).when(generator).usesFlatStudioTerrain(); - when(world.isChunkGenerated(0, 0)).thenReturn(true); - prefetch.invoke(generator, generated, world); + when(world.getChunkAtAsync(0, 0, false)).thenReturn(CompletableFuture.completedFuture(mock(Chunk.class))); + ((CompletableFuture) prefetch.invoke(generator, generated, world)).get(5L, TimeUnit.SECONDS); verify(generated, never()).startStudioEntryHydrology(anyInt(), anyInt()); } @@ -99,8 +112,8 @@ public class BukkitChunkGeneratorInitializationModeTest { public void authoringStartupDoesNotPrefetchTerrainHydrology() throws IOException { String source = Files.readString(Path.of( "src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java")); - int start = source.indexOf("private void prefetchSpawnHydrology("); - int end = source.indexOf("private int hydrologyTileSize()", start); + int start = source.indexOf("private CompletableFuture prefetchSpawnHydrology("); + int end = source.indexOf("private void updateSpawnLocation(", start); String prefetch = source.substring(start, end); assertTrue(prefetch.indexOf("if (usesFlatStudioTerrain())") >= 0); diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorSpawnHydrologyTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorSpawnHydrologyTest.java new file mode 100644 index 000000000..f61dcdc66 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorSpawnHydrologyTest.java @@ -0,0 +1,237 @@ +package art.arcane.iris.engine.platform; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.StudioMode; +import org.bukkit.Chunk; +import org.bukkit.World; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class BukkitChunkGeneratorSpawnHydrologyTest { + @Test + public void startupWaitsAsynchronouslyWithoutHoldingGenerationAdmission() throws Exception { + Fixture fixture = new Fixture(); + CompletableFuture prefetch = fixture.prefetch(); + + assertFalse(prefetch.isDone()); + assertEquals(2, fixture.gate.availablePermits()); + verifyNoInteractions(fixture.hydrology); + verify(fixture.world).getChunkAtAsync(0, 0, false); + verify(fixture.world, never()).isChunkGenerated(anyInt(), anyInt()); + verify(fixture.world, never()).getChunkAtAsync(anyInt(), anyInt(), eq(true)); + + fixture.lookup.complete(null); + prefetch.get(5L, TimeUnit.SECONDS); + + verify(fixture.hydrology).prefetchArea(-256, -256, 255, 255, 0, 0); + assertEquals(2, fixture.gate.availablePermits()); + } + + @Test + public void existingSpawnDoesNotPrefetchHydrology() throws Exception { + Fixture fixture = new Fixture(); + CompletableFuture prefetch = fixture.prefetch(); + fixture.lookup.complete(mock(Chunk.class)); + prefetch.get(5L, TimeUnit.SECONDS); + + verifyNoInteractions(fixture.hydrology); + } + + @Test + public void lateLookupDoesNotEnterClosingGenerator() throws Exception { + Fixture fixture = new Fixture(); + CompletableFuture prefetch = fixture.prefetch(); + setField(fixture.generator, "closing", true); + fixture.lookup.complete(null); + prefetch.get(5L, TimeUnit.SECONDS); + + verifyNoInteractions(fixture.hydrology); + assertEquals(2, fixture.gate.availablePermits()); + } + + @Test + public void closeRejectsCompletionAlreadyWaitingForAdmission() throws Exception { + Fixture fixture = new Fixture(); + CountDownLatch acquiring = new CountDownLatch(1); + doAnswer(invocation -> { + acquiring.countDown(); + return invocation.callRealMethod(); + }).when(fixture.generator).acquireGenerationStage(anyString()); + CompletableFuture prefetch = fixture.prefetch(); + fixture.gate.acquireExclusive(); + try { + fixture.lookup.complete(null); + assertTrue(acquiring.await(5L, TimeUnit.SECONDS)); + setField(fixture.generator, "closing", true); + } finally { + fixture.gate.releaseExclusive(); + } + prefetch.get(5L, TimeUnit.SECONDS); + + verifyNoInteractions(fixture.hydrology); + assertEquals(2, fixture.gate.availablePermits()); + } + + @Test + public void lateLookupDoesNotEnterReplacementEngine() throws Exception { + Fixture fixture = new Fixture(); + CompletableFuture prefetch = fixture.prefetch(); + fixture.generator.setEngine(mock(Engine.class)); + fixture.lookup.complete(null); + prefetch.get(5L, TimeUnit.SECONDS); + + verifyNoInteractions(fixture.hydrology); + assertEquals(2, fixture.gate.availablePermits()); + } + + @Test + public void lateLookupDoesNotEnterSealedEngine() throws Exception { + Fixture fixture = new Fixture(); + CompletableFuture prefetch = fixture.prefetch(); + when(fixture.engine.isClosing()).thenReturn(true); + fixture.lookup.complete(null); + prefetch.get(5L, TimeUnit.SECONDS); + + verifyNoInteractions(fixture.hydrology); + } + + @Test + public void lateLookupDoesNotEnterReplacementHydrology() throws Exception { + Fixture fixture = new Fixture(); + CompletableFuture prefetch = fixture.prefetch(); + IrisHydrologyRuntime replacement = mock(IrisHydrologyRuntime.class); + when(fixture.complex.getHydrologyRuntime()).thenReturn(replacement); + fixture.lookup.complete(null); + prefetch.get(5L, TimeUnit.SECONDS); + + verifyNoInteractions(fixture.hydrology, replacement); + } + + @Test + public void normalStudioUsesTrackedEntryPreparation() throws Exception { + Fixture fixture = new Fixture(); + setField(fixture.generator, "studio", true); + CompletableFuture prefetch = fixture.prefetch(); + fixture.lookup.complete(null); + prefetch.get(5L, TimeUnit.SECONDS); + + verify(fixture.engine).startStudioEntryHydrology(0, 0); + verifyNoInteractions(fixture.hydrology); + } + + @Test + public void failedLookupDoesNotStartHydrology() throws Exception { + Fixture fixture = new Fixture(); + CompletableFuture prefetch = fixture.prefetch(); + IllegalStateException failure = new IllegalStateException("Native chunk lookup failed"); + fixture.lookup.completeExceptionally(failure); + + ExecutionException result = assertThrows(ExecutionException.class, + () -> prefetch.get(5L, TimeUnit.SECONDS)); + assertSame(failure, result.getCause()); + verifyNoInteractions(fixture.hydrology); + assertEquals(2, fixture.gate.availablePermits()); + } + + @Test + public void lateLookupFailureDoesNotFailClosedGenerator() throws Exception { + Fixture fixture = new Fixture(); + CompletableFuture prefetch = fixture.prefetch(); + setField(fixture.generator, "closing", true); + fixture.lookup.completeExceptionally(new IllegalStateException("World unloaded")); + prefetch.get(5L, TimeUnit.SECONDS); + + verifyNoInteractions(fixture.hydrology); + } + + @Test + public void admittedPlanningFailureSurvivesConcurrentClose() throws Exception { + Fixture fixture = new Fixture(); + IllegalStateException failure = new IllegalStateException("Planner scheduling failed"); + doAnswer(invocation -> { + setField(fixture.generator, "closing", true); + throw failure; + }).when(fixture.hydrology).prefetchArea(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); + CompletableFuture prefetch = fixture.prefetch(); + fixture.lookup.complete(null); + + ExecutionException result = assertThrows(ExecutionException.class, + () -> prefetch.get(5L, TimeUnit.SECONDS)); + assertSame(failure, result.getCause()); + assertEquals(2, fixture.gate.availablePermits()); + } + + private static void setField(BukkitChunkGenerator generator, String name, Object value) + throws ReflectiveOperationException { + Field field = BukkitChunkGenerator.class.getDeclaredField(name); + field.setAccessible(true); + field.set(generator, value); + } + + private static final class Fixture { + private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + private final BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(2, generator::isClosing); + private final IrisEngine engine = mock(IrisEngine.class); + private final IrisComplex complex = mock(IrisComplex.class); + private final IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class); + private final World world = mock(World.class); + private final CompletableFuture lookup = new CompletableFuture<>(); + + private Fixture() throws ReflectiveOperationException { + setField(generator, "loadLock", gate); + generator.setEngine(engine); + doReturn(false).when(generator).usesFlatStudioTerrain(); + when(engine.getComplex()).thenReturn(complex); + when(complex.getHydrologyRuntime()).thenReturn(hydrology); + HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class, RETURNS_DEEP_STUBS); + when(settings.routing().tileSize()).thenReturn(512); + when(hydrology.settings()).thenReturn(settings); + IrisDimension dimension = mock(IrisDimension.class); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL); + when(world.getMinHeight()).thenReturn(-64); + when(world.getMaxHeight()).thenReturn(320); + when(world.getChunkAtAsync(0, 0, false)).thenReturn(lookup); + when(world.isChunkGenerated(anyInt(), anyInt())) + .thenThrow(new AssertionError("World initialization must not query chunk status synchronously")); + } + + @SuppressWarnings("unchecked") + private CompletableFuture prefetch() throws ReflectiveOperationException { + Method prefetch = BukkitChunkGenerator.class.getDeclaredMethod( + "prefetchSpawnHydrology", Engine.class, World.class); + prefetch.setAccessible(true); + return (CompletableFuture) prefetch.invoke(generator, engine, world); + } + } +}