diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 651bc31ba..02974e32a 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -223,7 +223,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private IrisUpdateNotifier updateNotifier; private BukkitDebugDump debugDump; private volatile SettingsHotloadWatch settingsHotloadWatch; - private volatile Thread serverLifecycleThread; private volatile ServerShutdownBoundary serverShutdownBoundary; public static VolmitSender getSender() { @@ -832,7 +831,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public void addShutdownHook() { removeShutdownHook(); - serverLifecycleThread = Thread.currentThread(); serverShutdownBoundary = INMS.isBound() ? INMS.get().createServerShutdownBoundary() : null; shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook"); try { @@ -1164,7 +1162,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { if (generator == null) { continue; } - IrisToolbelt.beginWorldMaintenance(world, reason, true); + IrisToolbelt.beginWorldMaintenance(world, reason); if (!deferredShutdownGenerators.contains(generator)) { deferredShutdownGenerators.add(generator); } @@ -1176,52 +1174,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { if (!postStopFinisherStarted.compareAndSet(false, true)) { return; } - Thread activeServerThread = serverLifecycleThread; - if (activeServerThread == null) { - Iris.warn("Iris could not start its post-stop runtime finisher because the server lifecycle thread is unavailable."); - return; - } - - Thread finisher = new Thread(() -> { - if (!awaitServerThreadTermination(activeServerThread)) { - return; - } - finishDeferredRuntimeTeardown("post-server-stop", 30L); - }, "Iris-PostStop-Finisher"); + Thread finisher = new Thread(() -> finishRuntimeAfterServerShutdown("post-server-stop"), + "Iris-PostStop-Finisher"); finisher.setDaemon(false); finisher.start(); } - static boolean awaitServerThreadTermination(Thread serverThread) { - if (serverThread == null || serverThread == Thread.currentThread()) { - return false; - } - try { - serverThread.join(); - return true; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - Iris.reportError("Iris post-stop runtime finisher was interrupted.", e); - return false; - } - } - private void runShutdownHook() { if (startupBoundaryRestart.get()) { finishDeferredRuntimeTeardown("startup-boundary-restart-hook", 30L); return; } + finishRuntimeAfterServerShutdown("shutdown-hook"); + } + + private void finishRuntimeAfterServerShutdown(String reason) { if (!awaitServerShutdownBoundary()) { - SHUTDOWN_ERRORS.println("[Iris] Paper did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit."); + SHUTDOWN_ERRORS.println("[Iris] The server did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit."); return; } - finishDeferredRuntimeTeardown("shutdown-hook", 30L); + finishDeferredRuntimeTeardown(reason, 30L); } private boolean awaitServerShutdownBoundary() { ServerShutdownBoundary boundary = serverShutdownBoundary; if (boundary == null) { - return true; + return false; } try { return boundary.await( @@ -1508,12 +1486,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public int getIrisVersion() { String input = getDescription().getVersion(); int hyphenIndex = input.indexOf('-'); - if (hyphenIndex != -1) { - String result = input.substring(0, hyphenIndex); - result = result.replaceAll("\\.", ""); - return Integer.parseInt(result); + String release = hyphenIndex < 0 ? input : input.substring(0, hyphenIndex); + if (!release.matches("[0-9]+(?:\\.[0-9]+)*")) { + return -1; + } + try { + return Integer.parseInt(release.replace(".", "")); + } catch (NumberFormatException invalid) { + return -1; } - return -1; } public int getMCVersion() { diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownMaintenanceTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownMaintenanceTest.java new file mode 100644 index 000000000..205d209b4 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownMaintenanceTest.java @@ -0,0 +1,230 @@ +package art.arcane.iris; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.GenerationSessionLease; +import art.arcane.iris.generation.runtime.GenerationSessionManager; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.platform.bukkit.nms.INMS; +import art.arcane.iris.platform.bukkit.nms.INMSBinding; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.platform.generation.PlatformChunkGenerator; +import art.arcane.iris.studio.view.PregeneratorJob; +import art.arcane.iris.testsupport.PlatformBinding; +import art.arcane.iris.world.IrisToolbelt; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.WorldMaintenance; +import art.arcane.iris.world.runtime.BukkitEnginePlatformHooks; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.junit.ClassRule; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +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.verify; +import static org.mockito.Mockito.when; + +public class IrisShutdownMaintenanceTest { + @ClassRule + public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform(); + + @Test + public void finisherRetainsResourcesWhileTheAuthoritativeBoundaryIsPending() throws Exception { + try (Fixture fixture = new Fixture()) { + ServerShutdownBoundary boundary = mock(ServerShutdownBoundary.class); + CountDownLatch waiting = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference finisher = new AtomicReference<>(); + when(boundary.await(anyLong(), eq(TimeUnit.SECONDS))).thenAnswer(invocation -> { + finisher.set(Thread.currentThread()); + waiting.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return false; + }); + setField(fixture.plugin, "serverShutdownBoundary", boundary); + fixture.quiesce(); + fixture.startFinisher(); + try { + assertTrue(waiting.await(5, TimeUnit.SECONDS)); + fixture.startFinisher(); + verify(fixture.generator, never()).closeAsync(); + verify(fixture.binding, never()).releasePluginClassLoaderClose(); + } finally { + release.countDown(); + Thread running = finisher.get(); + if (running != null) { + running.join(5_000L); + assertFalse(running.isAlive()); + } + } + verify(boundary, times(1)).await(anyLong(), eq(TimeUnit.SECONDS)); + verify(fixture.generator, never()).closeAsync(); + } + } + + @Test + public void missingShutdownBoundaryDoesNotAuthorizeResourceRelease() throws Exception { + try (Fixture fixture = new Fixture()) { + Method method = Iris.class.getDeclaredMethod("awaitServerShutdownBoundary"); + method.setAccessible(true); + assertEquals(false, method.invoke(fixture.plugin)); + } + } + + @Test + public void serverStopKeepsFoliaGenerationAdmittedUntilTheServerDrains() throws Exception { + try (Fixture fixture = new Fixture()) { + GenerationSessionManager sessions = new GenerationSessionManager(); + try (GenerationSessionLease active = sessions.acquireForEngine(fixture.engine, "active_chunk")) { + fixture.quiesce(); + + assertTrue(WorldMaintenance.isWorldMaintenanceActive(fixture.identity)); + assertFalse(fixture.hooks.shouldRefuseGeneration(fixture.engine)); + try (GenerationSessionLease queued = sessions.acquireForEngine(fixture.engine, "queued_chunk")) { + assertEquals(active.sessionId(), queued.sessionId()); + assertEquals(2, sessions.activeLeases()); + } + verify(fixture.generator).quiesceForServerShutdown(); + verify(fixture.generator, never()).closeAsync(); + fixture.nms.verify(INMS::get); + verify(fixture.binding).deferPluginClassLoaderClose(); + verify(fixture.binding, never()).releasePluginClassLoaderClose(); + } + assertEquals(0, sessions.activeLeases()); + fixture.drain(); + verify(fixture.generator).closeAsync(); + } + } + + @Test + public void serverStopDoesNotOverrideExistingBlockingMaintenance() throws Exception { + try (Fixture fixture = new Fixture()) { + WorldMaintenance.beginWorldMaintenance(fixture.identity, "world_unload", true); + try { + fixture.quiesce(); + assertTrue(fixture.hooks.shouldRefuseGeneration(fixture.engine)); + } finally { + WorldMaintenance.endWorldMaintenance(fixture.identity, "world_unload", true); + } + assertFalse(fixture.hooks.shouldRefuseGeneration(fixture.engine)); + } + } + + @Test + public void manualDisableStillBlocksGenerationBeforeClosingTheGenerator() throws Exception { + try (Fixture fixture = new Fixture()) { + when(fixture.generator.closeAsync()).thenAnswer(invocation -> { + assertTrue(fixture.hooks.shouldRefuseGeneration(fixture.engine)); + return CompletableFuture.completedFuture(null); + }); + try { + fixture.drain(); + assertTrue(fixture.hooks.shouldRefuseGeneration(fixture.engine)); + verify(fixture.generator).closeAsync(); + verify(fixture.generator, never()).quiesceForServerShutdown(); + } finally { + WorldMaintenance.endWorldMaintenance(fixture.identity, "manual_disable", true); + } + } + } + + private static void setField(Iris plugin, String name, Object value) throws ReflectiveOperationException { + Field field = Iris.class.getDeclaredField(name); + field.setAccessible(true); + field.set(plugin, value); + } + + private static final class Fixture implements AutoCloseable { + private final Iris plugin = mock(Iris.class); + private final World world = mock(World.class); + private final IrisWorld irisWorld = mock(IrisWorld.class); + private final Engine engine = mock(Engine.class); + private final PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + private final BukkitEnginePlatformHooks hooks = new BukkitEnginePlatformHooks(); + private final INMSBinding binding = mock(INMSBinding.class); + private final String identity = "iris:shutdown_test"; + private final IrisSettings previousSettings = IrisSettings.settings; + private final MockedStatic bukkit = mockStatic(Bukkit.class); + private final MockedStatic toolbelt = mockStatic(IrisToolbelt.class, CALLS_REAL_METHODS); + private final MockedStatic worlds = mockStatic(BukkitWorldBinding.class); + private final MockedStatic services = mockStatic(IrisServices.class); + private final MockedStatic nms = mockStatic(INMS.class); + private final MockedStatic tasks = mockStatic(J.class, CALLS_REAL_METHODS); + private final MockedStatic pregenerator = mockStatic(PregeneratorJob.class); + private int quiesces; + + private Fixture() throws ReflectiveOperationException { + IrisSettings.settings = new IrisSettings(); + setField(plugin, "serverStopTeardownDeferred", new AtomicBoolean()); + setField(plugin, "postStopFinisherStarted", new AtomicBoolean()); + setField(plugin, "deferredShutdownGenerators", new CopyOnWriteArrayList()); + when(world.getKey()).thenReturn(NamespacedKey.fromString(identity)); + when(engine.getWorld()).thenReturn(irisWorld); + when(irisWorld.hasPlatformWorld()).thenReturn(true); + when(irisWorld.identity()).thenReturn(identity); + when(generator.closeAsync()).thenReturn(CompletableFuture.completedFuture(null)); + bukkit.when(Bukkit::getWorlds).thenReturn(List.of(world)); + toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator); + worlds.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world); + nms.when(INMS::get).thenReturn(binding); + tasks.when(J::isFolia).thenReturn(true); + } + + private void quiesce() throws ReflectiveOperationException { + Method method = Iris.class.getDeclaredMethod("quiesceRuntimeForServerShutdown", String.class); + method.setAccessible(true); + method.invoke(plugin, "server_stop"); + quiesces++; + } + + private void drain() throws ReflectiveOperationException { + Method method = Iris.class.getDeclaredMethod("drainWorldGenerators", String.class, long.class); + method.setAccessible(true); + method.invoke(plugin, "manual_disable", 5L); + } + + private void startFinisher() throws ReflectiveOperationException { + Method method = Iris.class.getDeclaredMethod("startPostStopFinisher"); + method.setAccessible(true); + method.invoke(plugin); + } + + @Override + public void close() { + for (int index = 0; index < quiesces; index++) { + WorldMaintenance.endWorldMaintenance(identity, "server_stop"); + } + pregenerator.close(); + tasks.close(); + nms.close(); + services.close(); + worlds.close(); + toolbelt.close(); + bukkit.close(); + IrisSettings.settings = previousSettings; + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisVersionNumberTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisVersionNumberTest.java new file mode 100644 index 000000000..4e7a9d3f5 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisVersionNumberTest.java @@ -0,0 +1,67 @@ +package art.arcane.iris; + +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginDescriptionFile; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.InputStream; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public class IrisVersionNumberTest { + @Test + public void releaseAndQualifiedBuildsUseTheSameVersionNumber() { + for (String version : List.of("4.3.3", "4.3.3-SNAPSHOT", "4.3.3-test")) { + assertEquals(version, 433, plugin(version).getIrisVersion()); + } + } + + @Test + public void processedPluginMetadataProvidesAValidEngineVersion() throws Exception { + try (InputStream stream = getClass().getResourceAsStream("/plugin.yml")) { + assertNotNull(stream); + PluginDescriptionFile description = new PluginDescriptionFile(stream); + Iris plugin = mock(Iris.class, CALLS_REAL_METHODS); + doReturn(description).when(plugin).getDescription(); + + assertNotEquals(-1, plugin.getIrisVersion()); + } + } + + @Test + public void malformedAndOverflowingReleaseNumbersStayInvalid() { + for (String version : List.of("development", "4..3", ".4.3.3", "4.3.3.", "4.3a.3-test", + "2147483648", "999999999999999999999-SNAPSHOT", "-SNAPSHOT")) { + assertEquals(version, -1, plugin(version).getIrisVersion()); + } + } + + @Test + public void supportedMinecraftVersionsRetainTheirEngineNumbers() { + Iris plugin = mock(Iris.class, CALLS_REAL_METHODS); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getVersion).thenReturn("26.1.2-74-ver/26.1.2@e4e17fc (MC: 26.1.2)"); + assertEquals(2612, plugin.getMCVersion()); + bukkit.when(Bukkit::getVersion).thenReturn("26.1.2-8-ver/26.1.x@62dc0f2 (MC: 26.1.2)"); + assertEquals(2612, plugin.getMCVersion()); + bukkit.when(Bukkit::getVersion).thenReturn("26.2-133 (MC: 26.2)"); + assertEquals(262, plugin.getMCVersion()); + bukkit.when(Bukkit::getVersion).thenReturn("26.3-169 (MC: 26.3)"); + assertEquals(263, plugin.getMCVersion()); + } + } + + private static Iris plugin(String version) { + Iris plugin = mock(Iris.class, CALLS_REAL_METHODS); + doReturn(new PluginDescriptionFile("Iris", version, Iris.class.getName())).when(plugin).getDescription(); + return plugin; + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/CaveCarveScratch.java b/core/src/main/java/art/arcane/iris/generation/mantle/CaveCarveScratch.java index 061c88ac7..dd456abf5 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/CaveCarveScratch.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/CaveCarveScratch.java @@ -40,6 +40,7 @@ final class CaveCarveScratch { final double[] planeThresholdLimit = new double[256]; final boolean[] planeCarve = new boolean[256]; final double[] adaptivePlaneDensity = new double[81]; + final CaveDensityGrid.Column[] densityColumns = new CaveDensityGrid.Column[16]; double[] verticalDensityLower = new double[9]; double[] verticalDensityUpper = new double[9]; int verticalDensityLowerY = Integer.MIN_VALUE; @@ -79,8 +80,9 @@ final class CaveCarveScratch { int adaptiveGeometryAxisCells = -1; boolean fullWeightsInitialized; - void releaseSections() { + void releaseReferences() { Arrays.fill(sectionMatter, null); Arrays.fill(sectionSlices, null); + Arrays.fill(densityColumns, null); } } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/CaveDensityGrid.java b/core/src/main/java/art/arcane/iris/generation/mantle/CaveDensityGrid.java index b0064d1a2..824523a64 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/CaveDensityGrid.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/CaveDensityGrid.java @@ -37,7 +37,7 @@ final class CaveDensityGrid { if (cached == null) { cached = columns.putIfAbsent(key, new Column(owner, latticeX, latticeZ, height)); } - return cached.owner == owner && cached.latticeX == latticeX && cached.latticeZ == latticeZ ? cached : null; + return cached.matches(owner, latticeX, latticeZ) ? cached : null; } void clear() { @@ -57,6 +57,10 @@ final class CaveDensityGrid { segments = new AtomicReferenceArray<>(Math.ceilDiv(Math.max(1, height), SEGMENT_SIZE)); } + boolean matches(Object owner, int latticeX, int latticeZ) { + return this.owner == owner && this.latticeX == latticeX && this.latticeZ == latticeZ; + } + double get(int y) { int segment = y >> SEGMENT_SHIFT; if (segment >= segments.length()) { diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java index 0f6858c6e..baa041b15 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java @@ -449,7 +449,7 @@ public class IrisCaveCarver3D { return carved; } finally { - scratchCache.get().releaseSections(); + scratchCache.get().releaseReferences(); carving.set(nested); if (nested) { scratchCache.set(previousScratch); @@ -2466,8 +2466,19 @@ public class IrisCaveCarver3D { private double latticeDensity(CaveCarveScratch scratch, int x, int y, int z) { int latticeMask = (1 << CaveDensityGrid.LATTICE_SHIFT) - 1; - CaveDensityGrid.Column column = ((x | z) & latticeMask) != 0 ? null : densityGrid.column(this, densityGridOwner, - densityMaximumY + 1, x >> CaveDensityGrid.LATTICE_SHIFT, z >> CaveDensityGrid.LATTICE_SHIFT); + if (((x | z) & latticeMask) != 0) { + return samplePlaneDensity(scratch, x, y, z); + } + int latticeX = x >> CaveDensityGrid.LATTICE_SHIFT; + int latticeZ = z >> CaveDensityGrid.LATTICE_SHIFT; + int slot = ((latticeX & 3) << 2) | (latticeZ & 3); + CaveDensityGrid.Column column = parallelDensity ? scratch.densityColumns[slot] : null; + if (column == null || !column.matches(this, latticeX, latticeZ)) { + column = densityGrid.column(this, densityGridOwner, densityMaximumY + 1, latticeX, latticeZ); + if (parallelDensity) { + scratch.densityColumns[slot] = column; + } + } if (column == null) { return samplePlaneDensity(scratch, x, y, z); } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java index e6963e27c..421f58386 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java @@ -53,6 +53,7 @@ import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.withSettings; public class IrisCaveCarver3DNearParityTest { @@ -299,10 +300,10 @@ public class IrisCaveCarver3DNearParityTest { } @Test - public void carvingReleasesSectionReferencesAfterSuccessAndFailure() throws Exception { + public void carvingReleasesReferencesAfterSuccessAndFailure() throws Exception { for (boolean fail : new boolean[]{false, true}) { Engine engine = createEngine(128, 110); - IrisCaveProfile profile = createProfile(false, false).setAdaptiveSampling(false) + IrisCaveProfile profile = createProfile(false, false).setAdaptiveSampling(true) .setAllowFluid(false).setDetailWeight(0D) .setDensityThreshold(new IrisStyledRange(2D, 2D, new IrisGeneratorStyle(NoiseStyle.FLAT))); IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); @@ -334,6 +335,9 @@ public class IrisCaveCarver3DNearParityTest { for (MatterSlice slice : scratch.sectionSlices) { assertNull(slice); } + for (CaveDensityGrid.Column column : scratch.densityColumns) { + assertNull(column); + } } } @@ -1074,6 +1078,153 @@ public class IrisCaveCarver3DNearParityTest { } } + @Test + public void retainedLatticeColumnsPreserveRawSamplesThroughEvictionAndClear() throws Exception { + Method latticeDensity = IrisCaveCarver3D.class.getDeclaredMethod("latticeDensity", + CaveCarveScratch.class, int.class, int.class, int.class); + latticeDensity.setAccessible(true); + Method planeDensity = IrisCaveCarver3D.class.getDeclaredMethod("samplePlaneDensity", + CaveCarveScratch.class, int.class, int.class, int.class); + planeDensity.setAccessible(true); + Engine engine = createEngine(128, 110); + for (boolean warp : new boolean[]{false, true}) { + for (boolean modules : new boolean[]{false, true}) { + AtomicInteger lookups = new AtomicInteger(); + CaveDensityGrid grid = countingGrid(4, lookups); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, createProfile(warp, modules), grid); + CaveCarveScratch retained = new CaveCarveScratch(); + CaveCarveScratch reference = new CaveCarveScratch(); + for (int y = 0; y < 128; y++) { + if (y == 64) { + grid.clear(); + } + for (int x = -16; x <= 0; x += 8) { + for (int z = 32; z <= 48; z += 8) { + double expected = (double) planeDensity.invoke(carver, reference, x, y, z); + double actual = (double) latticeDensity.invoke(carver, retained, x, y, z); + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + } + } + assertEquals(Double.doubleToRawLongBits((double) planeDensity.invoke(carver, reference, -15, y, 33)), + Double.doubleToRawLongBits((double) latticeDensity.invoke(carver, retained, -15, y, 33))); + } + assertEquals(9, lookups.get()); + retained.releaseReferences(); + latticeDensity.invoke(carver, retained, -16, 1, 32); + assertEquals(10, lookups.get()); + double expected = (double) planeDensity.invoke(carver, reference, 16, 1, 32); + double actual = (double) latticeDensity.invoke(carver, retained, 16, 1, 32); + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + assertEquals(11, lookups.get()); + IrisCaveCarver3D reloaded = new IrisCaveCarver3D(engine, + createProfile(warp, modules).setBaseWeight(0.73D), grid); + expected = (double) planeDensity.invoke(reloaded, new CaveCarveScratch(), 16, 1, 32); + actual = (double) latticeDensity.invoke(reloaded, retained, 16, 1, 32); + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + assertEquals(12, lookups.get()); + } + } + } + + @Test + public void retainedLatticeColumnsMatchOriginalCarvesAndReduceLookups() throws Exception { + Field fixedDensity = IrisCaveCarver3D.class.getDeclaredField("parallelDensity"); + fixedDensity.setAccessible(true); + Engine engine = createEngine(128, 110); + for (boolean warp : new boolean[]{false, true}) { + for (boolean modules : new boolean[]{false, true}) { + AtomicInteger originalLookups = new AtomicInteger(); + AtomicInteger retainedLookups = new AtomicInteger(); + IrisCaveCarver3D original = new IrisCaveCarver3D(engine, + createProfile(warp, modules), countingGrid(4096, originalLookups)); + fixedDensity.set(original, false); + IrisCaveCarver3D retained = new IrisCaveCarver3D(engine, + createProfile(warp, modules), countingGrid(4096, retainedLookups)); + for (int chunkX = -1; chunkX <= 0; chunkX++) { + WriterCapture expected = createWriterCapture(128); + WriterCapture actual = createWriterCapture(128); + assertEquals(original.carve(expected.writer, chunkX, -3, fullWeights(), 0D, 0D, + null, filledHeights(110)), + retained.carve(actual.writer, chunkX, -3, fullWeights(), 0D, 0D, + null, filledHeights(110))); + assertFalse(expected.carvedCells.isEmpty()); + assertEquals(expected.carvedCells, actual.carvedCells); + assertEquals(expected.carvedLiquids, actual.carvedLiquids); + } + assertEquals(18, retainedLookups.get()); + assertTrue(originalLookups.get() > retainedLookups.get() * 50); + } + } + } + + @Test + public void expressionDensityDoesNotRetainEvictedColumns() throws Exception { + Engine engine = createEngine(128, 110); + CNG density = mock(CNG.class, withSettings().stubOnly()); + AtomicInteger samples = new AtomicInteger(); + doAnswer(invocation -> samples.incrementAndGet() * 0.125D).when(density) + .noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble()); + IrisGeneratorStyle style = mock(IrisGeneratorStyle.class, withSettings().stubOnly()); + doReturn("density_expression").when(style).getExpression(); + doReturn(density).when(style).create(any(RNG.class), any(IrisData.class)); + AtomicInteger lookups = new AtomicInteger(); + CaveDensityGrid grid = countingGrid(4, lookups); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, + createProfile(false, false).setBaseDensityStyle(style).setDetailWeight(0D), grid); + CaveCarveScratch scratch = new CaveCarveScratch(); + Method latticeDensity = IrisCaveCarver3D.class.getDeclaredMethod("latticeDensity", + CaveCarveScratch.class, int.class, int.class, int.class); + latticeDensity.setAccessible(true); + double first = (double) latticeDensity.invoke(carver, scratch, -16, 20, 32); + grid.clear(); + double second = (double) latticeDensity.invoke(carver, scratch, -16, 20, 32); + assertNotEquals(Double.doubleToRawLongBits(first), Double.doubleToRawLongBits(second)); + assertEquals(2, samples.get()); + assertEquals(2, lookups.get()); + for (CaveDensityGrid.Column column : scratch.densityColumns) { + assertNull(column); + } + } + + @Test + public void nestedAdaptiveCarvingPreservesRetainedColumns() throws Exception { + Engine engine = createEngine(128, 110); + IrisCaveProfile profile = createProfile(true, true); + WriterCapture expected = createWriterCapture(128); + new IrisCaveCarver3D(engine, profile).carve(expected.writer, -1, 2, + fullWeights(), 0D, 0D, null, filledHeights(110)); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + CNG original = (CNG) baseDensityField.get(carver); + CNG density = mock(CNG.class, withSettings().stubOnly()); + AtomicBoolean entered = new AtomicBoolean(); + WriterCapture nested = createWriterCapture(128); + doAnswer(invocation -> { + if (entered.compareAndSet(false, true)) { + carver.carve(nested.writer, -9, -11, fullWeights(), 0D, 0D, null, filledHeights(80)); + } + double x = invocation.getArgument(0); + double y = invocation.getArgument(1); + double z = invocation.getArgument(2); + return original.noiseFastSigned3D(x, y, z); + }).when(density).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble()); + baseDensityField.set(carver, density); + WriterCapture actual = createWriterCapture(128); + carver.carve(actual.writer, -1, 2, fullWeights(), 0D, 0D, null, filledHeights(110)); + assertTrue(entered.get()); + assertFalse(nested.carvedCells.isEmpty()); + assertEquals(expected.carvedCells, actual.carvedCells); + assertEquals(expected.carvedLiquids, actual.carvedLiquids); + } + + private CaveDensityGrid countingGrid(int capacity, AtomicInteger lookups) { + CaveDensityGrid grid = spy(new CaveDensityGrid(capacity)); + doAnswer(invocation -> { + lookups.incrementAndGet(); + return invocation.callRealMethod(); + }).when(grid).column(any(), anyInt(), anyInt(), anyInt(), anyInt()); + return grid; + } + private void assertExactParity(boolean warp, boolean modules, boolean adaptiveSampling) throws Exception { Engine engine = createEngine(96, 90); double[] columnWeights = fullWeights(); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java index 093f7c1bc..1704b654a 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java @@ -102,6 +102,39 @@ public class EngineShutdownDrainTest { } } + @Test + public void closeKeepsRuntimeOpenUntilAdmittedGenerationFinishes() throws Exception { + ShutdownFixture fixture = new ShutdownFixture(); + GenerationSessionManager sessions = spy(new GenerationSessionManager()); + when(fixture.engine.getGenerationSessions()).thenReturn(sessions); + CountDownLatch draining = new CountDownLatch(1); + doAnswer(invocation -> { + draining.countDown(); + return invocation.callRealMethod(); + }).when(sessions).sealAndAwait("close", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true); + GenerationSessionLease activeGeneration = sessions.acquire("chunk_generate"); + try (ExecutorService worker = Executors.newSingleThreadExecutor()) { + Future closing = worker.submit(() -> { + try (MockedStatic ownership = mockStatic(NativeStructureOwnershipStore.class)) { + fixture.shutdown.close(); + } + }); + try { + assertTrue(draining.await(5, TimeUnit.SECONDS)); + assertFalse(closing.isDone()); + verify(fixture.complex, never()).close(); + verify(fixture.mantle, never()).close(); + verify(fixture.target, never()).close(); + } finally { + activeGeneration.close(); + } + closing.get(5, TimeUnit.SECONDS); + verify(fixture.complex).close(); + verify(fixture.mantle).close(); + assertTrue(fixture.engine.closed); + } + } + @Test public void closingRuntimeAllowsItsBackgroundWorkersToOpenCapturedScopes() throws Exception { ShutdownFixture fixture = new ShutdownFixture(); diff --git a/probe/build.gradle b/probe/build.gradle index d9dbc22ec..bc874dddb 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -17,6 +17,7 @@ dependencies { implementation(project(':core')) compileOnly(libs.spigot) testImplementation('junit:junit:4.13.2') + testImplementation('org.mockito:mockito-core:5.23.0') testImplementation(libs.gson) testImplementation(testFixtures(project(':core'))) testImplementation('com.github.VolmitSoftware.VolmLib:volmlib-shared:master-SNAPSHOT') { diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java index c14475859..38c63808c 100644 --- a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java @@ -56,7 +56,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ExecutorService; -import java.util.stream.Stream; public final class GenerationProbe { private static final List REPORTED = Collections.synchronizedList(new ArrayList<>()); @@ -564,7 +563,7 @@ public final class GenerationProbe { } } try { - deleteRecursively(workRoot.toPath()); + ProbeWorkspaceCleanup.deleteRecursively(workRoot.toPath()); } catch (Throwable e) { failure = appendFailure(failure, e); } @@ -581,19 +580,6 @@ public final class GenerationProbe { return failure; } - private static void deleteRecursively(Path root) throws Exception { - if (!Files.exists(root)) { - return; - } - List paths; - try (Stream walk = Files.walk(root)) { - paths = walk.sorted(Comparator.reverseOrder()).toList(); - } - for (Path path : paths) { - Files.deleteIfExists(path); - } - } - private static List settleAndDrain() throws InterruptedException { List drained = new ArrayList<>(); long quietSince = System.currentTimeMillis(); diff --git a/probe/src/main/java/art/arcane/iris/probe/ProbeWorkspaceCleanup.java b/probe/src/main/java/art/arcane/iris/probe/ProbeWorkspaceCleanup.java new file mode 100644 index 000000000..0550b587c --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/ProbeWorkspaceCleanup.java @@ -0,0 +1,54 @@ +package art.arcane.iris.probe; + +import java.io.IOException; +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; + +final class ProbeWorkspaceCleanup { + private static final int MAXIMUM_ATTEMPTS = 4; + private static final SimpleFileVisitor DELETE_VISITOR = new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { + if (failure instanceof NoSuchFileException) { + return FileVisitResult.CONTINUE; + } + throw failure; + } + + @Override + public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException { + if (failure != null) { + throw failure; + } + Files.deleteIfExists(directory); + return FileVisitResult.CONTINUE; + } + }; + + private ProbeWorkspaceCleanup() { + } + + static void deleteRecursively(Path root) throws IOException { + for (int attempt = 1; ; attempt++) { + try { + Files.walkFileTree(root, DELETE_VISITOR); + return; + } catch (DirectoryNotEmptyException failure) { + if (attempt == MAXIMUM_ATTEMPTS) { + throw failure; + } + } + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java b/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java index 41a23172d..d5c0c36c0 100644 --- a/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java +++ b/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java @@ -43,7 +43,6 @@ import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -56,9 +55,9 @@ import java.util.concurrent.ForkJoinTask; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.LockSupport; -import java.util.stream.Stream; final class RealPackProbeSupport { + private static final long WORKER_DRAIN_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(120); private static final List REPORTED = Collections.synchronizedList(new ArrayList<>()); private RealPackProbeSupport() { @@ -157,7 +156,7 @@ final class RealPackProbeSupport { } private static void awaitGenerationWorkers() throws InterruptedException { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15); + long deadline = System.nanoTime() + WORKER_DRAIN_TIMEOUT_NANOS; List pools = new ArrayList<>(3); for (MultiBurst burst : List.of(MultiBurst.hydrology, MultiBurst.ioBurst, MultiBurst.burst)) { try { @@ -170,6 +169,10 @@ final class RealPackProbeSupport { throw new IllegalStateException("Probe generation worker barrier failed", failure); } } + awaitGenerationWorkers(pools, deadline); + } + + static void awaitGenerationWorkers(List pools, long deadline) throws InterruptedException { long[] previous = quietWorkerSnapshot(pools, deadline); while (true) { long[] current = quietWorkerSnapshot(pools, deadline); @@ -189,7 +192,8 @@ final class RealPackProbeSupport { throw new InterruptedException("Probe generation worker barrier interrupted"); } if (System.nanoTime() >= deadline) { - throw new IllegalStateException("Probe generation workers did not settle within 15 seconds"); + throw new IllegalStateException("Probe generation workers did not settle before the deadline: pool " + + (index + 1) + "/" + pools.size() + " " + pool); } long count = pool.getStealCount(); boolean quiet = pool.isQuiescent(); @@ -616,7 +620,7 @@ final class RealPackProbeSupport { if (clone.waitFor() == 0) { return destination; } - deleteRecursively(destination.toPath()); + ProbeWorkspaceCleanup.deleteRecursively(destination.toPath()); } Path sourcePath = source.toPath(); Files.walkFileTree(sourcePath, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, @@ -657,26 +661,13 @@ final class RealPackProbeSupport { private static Throwable deleteWorkRoot(File workRoot) { try { - deleteRecursively(workRoot.toPath()); + ProbeWorkspaceCleanup.deleteRecursively(workRoot.toPath()); return null; } catch (Throwable failure) { return failure; } } - private static void deleteRecursively(Path root) throws Exception { - if (!Files.exists(root)) { - return; - } - List paths; - try (Stream walk = Files.walk(root)) { - paths = walk.sorted(Comparator.reverseOrder()).toList(); - } - for (Path path : paths) { - Files.deleteIfExists(path); - } - } - private static Throwable appendFailure(Throwable failure, Throwable additional) { if (failure == null) { return additional; diff --git a/probe/src/test/java/art/arcane/iris/probe/ProbeWorkspaceCleanupTest.java b/probe/src/test/java/art/arcane/iris/probe/ProbeWorkspaceCleanupTest.java new file mode 100644 index 000000000..383d44439 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/ProbeWorkspaceCleanupTest.java @@ -0,0 +1,148 @@ +package art.arcane.iris.probe; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.nio.file.AccessDeniedException; +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.FileSystemException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Locale; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +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.junit.Assume.assumeNoException; +import static org.mockito.Mockito.mockStatic; + +public final class ProbeWorkspaceCleanupTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void deletesNestedContentsAndAcceptsMissingRoots() throws Exception { + Path root = temporaryFolder.newFolder("workspace").toPath(); + Files.writeString(Files.createDirectories(root.resolve("pack/regions")).resolve("region.json"), "{}"); + + ProbeWorkspaceCleanup.deleteRecursively(root); + ProbeWorkspaceCleanup.deleteRecursively(root); + + assertFalse(Files.exists(root)); + } + + @Test + public void rescansWhenAnotherThreadCreatesMetadataAfterDirectoryTraversal() throws Exception { + Path root = temporaryFolder.newFolder("late-metadata").toPath(); + Path nested = Files.createDirectory(root.resolve("pack")); + CountDownLatch deletingDirectory = new CountDownLatch(1); + CountDownLatch metadataWritten = new CountDownLatch(1); + ExecutorService writer = Executors.newSingleThreadExecutor(); + AtomicInteger attempts = new AtomicInteger(); + try (MockedStatic files = mockStatic(Files.class, invocation -> { + if (invocation.getMethod().getName().equals("deleteIfExists") && nested.equals(invocation.getArgument(0))) { + if (attempts.incrementAndGet() == 1) { + deletingDirectory.countDown(); + assertTrue(metadataWritten.await(5, TimeUnit.SECONDS)); + } + } + return invocation.callRealMethod(); + })) { + Future created = writer.submit(() -> { + assertTrue(deletingDirectory.await(5, TimeUnit.SECONDS)); + Files.writeString(nested.resolve(".DS_Store"), "metadata"); + metadataWritten.countDown(); + return null; + }); + + ProbeWorkspaceCleanup.deleteRecursively(root); + created.get(5, TimeUnit.SECONDS); + } finally { + deletingDirectory.countDown(); + writer.shutdownNow(); + } + + assertEquals(2, attempts.get()); + assertFalse(Files.exists(root)); + } + + @Test + public void stopsRescanningWhenTheDirectoryKeepsChanging() throws Exception { + Path root = temporaryFolder.newFolder("continuing-writes").toPath(); + AtomicInteger attempts = new AtomicInteger(); + try (MockedStatic files = mockStatic(Files.class, invocation -> { + if (invocation.getMethod().getName().equals("deleteIfExists") && root.equals(invocation.getArgument(0))) { + attempts.incrementAndGet(); + Files.writeString(root.resolve("late-file"), "content"); + } + return invocation.callRealMethod(); + })) { + assertThrows(DirectoryNotEmptyException.class, () -> ProbeWorkspaceCleanup.deleteRecursively(root)); + } + + assertEquals(4, attempts.get()); + assertTrue(Files.exists(root.resolve("late-file"))); + } + + @Test + public void propagatesOtherIoFailuresWithoutRetrying() throws Exception { + Path root = temporaryFolder.newFolder("denied").toPath(); + AccessDeniedException failure = new AccessDeniedException(root.toString()); + AtomicInteger attempts = new AtomicInteger(); + try (MockedStatic files = mockStatic(Files.class, invocation -> { + if (invocation.getMethod().getName().equals("deleteIfExists") && root.equals(invocation.getArgument(0))) { + attempts.incrementAndGet(); + throw failure; + } + return invocation.callRealMethod(); + })) { + assertSame(failure, assertThrows(AccessDeniedException.class, + () -> ProbeWorkspaceCleanup.deleteRecursively(root))); + } + assertEquals(1, attempts.get()); + } + + @Test + public void deletesLinksWithoutFollowingTheirTargets() throws Exception { + Path outside = temporaryFolder.newFolder("outside").toPath(); + Path retained = Files.writeString(outside.resolve("retained.txt"), "retained"); + Path root = temporaryFolder.newFolder("linked-workspace").toPath(); + createSymbolicLink(root.resolve("outside"), outside); + createSymbolicLink(root.resolve("missing"), outside.resolve("missing")); + + ProbeWorkspaceCleanup.deleteRecursively(root); + + assertEquals("retained", Files.readString(retained)); + assertFalse(Files.exists(root, LinkOption.NOFOLLOW_LINKS)); + Path rootLink = temporaryFolder.getRoot().toPath().resolve("root-link"); + createSymbolicLink(rootLink, outside); + ProbeWorkspaceCleanup.deleteRecursively(rootLink); + assertEquals("retained", Files.readString(retained)); + assertFalse(Files.exists(rootLink, LinkOption.NOFOLLOW_LINKS)); + } + + private static void createSymbolicLink(Path link, Path target) throws Exception { + try { + Files.createSymbolicLink(link, target); + } catch (UnsupportedOperationException | AccessDeniedException failure) { + assumeNoException(failure); + } catch (FileSystemException failure) { + if (!System.getProperty("os.name").startsWith("Windows") || failure.getReason() == null + || !failure.getReason().toLowerCase(Locale.ROOT).contains("privilege")) { + throw failure; + } + assumeNoException(failure); + } + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/RealPackProbeSupportBarrierTest.java b/probe/src/test/java/art/arcane/iris/probe/RealPackProbeSupportBarrierTest.java index a920f8cb7..712205da8 100644 --- a/probe/src/test/java/art/arcane/iris/probe/RealPackProbeSupportBarrierTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/RealPackProbeSupportBarrierTest.java @@ -17,13 +17,17 @@ import java.io.File; import java.net.URL; import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public final class RealPackProbeSupportBarrierTest { @@ -68,6 +72,89 @@ public final class RealPackProbeSupportBarrierTest { PlatformLeakGuard.requireClean("closed real-pack workspace", "end"); } + @Test(timeout = 10_000L) + public void waitsForActiveWorkWithinItsDeadline() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(1)) { + CountDownLatch entered = new CountDownLatch(1); + Future task = pool.submit(() -> { + entered.countDown(); + Thread.sleep(100L); + return null; + }); + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + RealPackProbeSupport.awaitGenerationWorkers(List.of(pool), + System.nanoTime() + TimeUnit.SECONDS.toNanos(5)); + assertTrue(task.isDone()); + task.get(); + } + } + + @Test(timeout = 10_000L) + public void rejectsUnsettledWorkAtItsDeadlineWithoutCancellingIt() throws Exception { + CountDownLatch release = new CountDownLatch(1); + try (ForkJoinPool pool = new ForkJoinPool(1)) { + CountDownLatch entered = new CountDownLatch(1); + Future task = pool.submit(() -> { + entered.countDown(); + release.await(); + return null; + }); + try { + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> RealPackProbeSupport.awaitGenerationWorkers(List.of(pool), + System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(50))); + assertTrue(failure.getMessage().contains("pool 1/1")); + assertTrue(failure.getMessage().contains("active =")); + assertFalse(task.isDone()); + } finally { + release.countDown(); + } + RealPackProbeSupport.awaitGenerationWorkers(List.of(pool), + System.nanoTime() + TimeUnit.SECONDS.toNanos(5)); + task.get(); + } + } + + @Test(timeout = 10_000L) + public void interruptionStopsAnActiveWorkerWait() throws Exception { + CountDownLatch release = new CountDownLatch(1); + try (ForkJoinPool pool = new ForkJoinPool(1)) { + CountDownLatch entered = new CountDownLatch(1); + Future task = pool.submit(() -> { + entered.countDown(); + release.await(); + return null; + }); + AtomicReference failure = new AtomicReference<>(); + CountDownLatch waiting = new CountDownLatch(1); + Thread waiter = new Thread(() -> { + waiting.countDown(); + try { + RealPackProbeSupport.awaitGenerationWorkers(List.of(pool), + System.nanoTime() + TimeUnit.SECONDS.toNanos(5)); + } catch (Throwable interrupted) { + failure.set(interrupted); + } + }); + try { + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + waiter.start(); + assertTrue(waiting.await(5L, TimeUnit.SECONDS)); + waiter.interrupt(); + waiter.join(5_000L); + assertFalse(waiter.isAlive()); + assertTrue(failure.get() instanceof InterruptedException); + assertFalse(task.isDone()); + } finally { + release.countDown(); + waiter.interrupt(); + waiter.join(5_000L); + } + task.get(); + } + } + private static void verifyCrossPoolContinuation(RealPackProbeSupport.EngineSession session, MultiBurst first, MultiBurst second) throws Exception { IllegalStateException failure = new IllegalStateException("nested generation failure");