mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-22 16:17:44 +00:00
Merged generation and lifecycle fixes
- Folia retains generation resources until native workers drain - Release builds record canonical world version metadata - Cave carving reuses fixed density columns - Offline probes handle concurrent directory writes - Worker checks allow bounded hydrology prefetch completion
This commit is contained in:
commit
d34dd48f74
14 files changed
+824
-78
No files matched your search
@@ -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() {
|
||||
|
||||
+230
@@ -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<Thread> 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> bukkit = mockStatic(Bukkit.class);
|
||||
private final MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class, CALLS_REAL_METHODS);
|
||||
private final MockedStatic<BukkitWorldBinding> worlds = mockStatic(BukkitWorldBinding.class);
|
||||
private final MockedStatic<IrisServices> services = mockStatic(IrisServices.class);
|
||||
private final MockedStatic<INMS> nms = mockStatic(INMS.class);
|
||||
private final MockedStatic<J> tasks = mockStatic(J.class, CALLS_REAL_METHODS);
|
||||
private final MockedStatic<PregeneratorJob> 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<PlatformChunkGenerator>());
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+153
-2
@@ -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();
|
||||
|
||||
@@ -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<NativeStructureOwnershipStore> 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();
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<Throwable> 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<Path> paths;
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
paths = walk.sorted(Comparator.reverseOrder()).toList();
|
||||
}
|
||||
for (Path path : paths) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Throwable> settleAndDrain() throws InterruptedException {
|
||||
List<Throwable> drained = new ArrayList<>();
|
||||
long quietSince = System.currentTimeMillis();
|
||||
|
||||
@@ -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<Path> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Throwable> 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<ForkJoinPool> 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<ForkJoinPool> 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<Path> paths;
|
||||
try (Stream<Path> 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;
|
||||
|
||||
@@ -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> 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> 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> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Throwable> 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");
|
||||
|
||||
Reference in new issue
Block a user