OctreeVolume: fail loudly on cell-pool exhaustion instead of hanging
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 20 Sep 2026 09:13:31 +0000 (12:13 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 20 Sep 2026 09:13:31 +0000 (12:13 +0300)
getNewCellPointer looped forever when the pool was full (wrap-rescan
with no exit). Two guards now: the usedCellsCount fast path (made honest
— the master cell at index 0 is now counted at initWorld) and an
authoritative full-scan-cycle check that throws IllegalStateException
with capacity + remediation in the message. Verified with a tiny-pool
harness: 7 allocations + master fill 8 cells, the 9th throws (was: hang).

Also: class javadoc now states the subsystem's demo-only status and the
traceCell coverage reality; OctreeVolumeTest (2 tests) pins the
exhaustion behavior with a timeout that fails on the old implementation.

src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/OctreeVolume.java
src/test/java/eu/svjatoslav/aukio/e3d/renderer/octree/OctreeVolumeTest.java [new file with mode: 0644]

index d07f08d..377192f 100755 (executable)
@@ -25,6 +25,14 @@ import static java.lang.Integer.min;
  * <p>Cell data is stored in parallel arrays ({@code cell1} through {@code cell8})
  * for memory efficiency. Each array stores different aspects of cell data.</p>
  *
+ * <p><b>Status:</b> demo-only subsystem (used by {@code OctreeDemo}).
+ * The ray-traversal core ({@code traceCell}, ~590 lines of per-octant
+ * code) has no unit coverage and is exercised only through the demo's
+ * golden image; treat changes there as unverified by anything except the
+ * golden. The cell-pool capacity is fixed at construction —
+ * {@link #getNewCellPointer()} throws {@link IllegalStateException} when
+ * the pool is exhausted (it used to hang in an infinite rescan loop).</p>
+ *
  * @see eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.RayTracer
  * @see eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.Ray
  */
@@ -315,8 +323,10 @@ public class OctreeVolume {
         for (int i = 0; i < bufferLength; i++)
             cell1[i] = CELL_STATE_UNUSED;
 
-        // initialize master cell
+        // initialize master cell (occupies index 0 without being
+        // CELL_STATE_UNUSED — count it so usedCellsCount stays honest)
         clearCell(0);
+        usedCellsCount = 1;
     }
 
     /**
@@ -331,9 +341,21 @@ public class OctreeVolume {
 
     /**
      * Scans cells arrays and returns pointer to found unused cell.
+     *
+     * <p>Fails loudly on pool exhaustion — the previous version looped
+     * forever, rescanning the full pool on every wrap. The authoritative
+     * check is a full scan cycle back to the start position (the
+     * {@code usedCellsCount} fast path alone is insufficient: the master
+     * cell at index 0 occupies a slot without being counted).</p>
+     *
      * @return pointer to found unused cell
+     * @throws IllegalStateException when the cell pool is full
      */
     public int getNewCellPointer() {
+        if (usedCellsCount >= cell1.length)
+            throw poolExhausted();
+
+        final int start = cellAllocationPointer;
         while (true) {
             // ensure that cell allocation pointer is in bounds
             if (cellAllocationPointer >= cell1.length)
@@ -345,11 +367,23 @@ public class OctreeVolume {
 
                 usedCellsCount++;
                 return cellAllocationPointer;
-            } else
-                cellAllocationPointer++;
+            }
+
+            cellAllocationPointer++;
+            if (cellAllocationPointer == start)
+                throw poolExhausted();
         }
     }
 
+    /** Builds the pool-exhaustion exception (shared by both guards). */
+    private IllegalStateException poolExhausted() {
+        return new IllegalStateException(
+                "Octree cell pool exhausted: all " + cell1.length
+                        + " cells are in use. Increase the pool size"
+                        + " at OctreeVolume construction or reduce"
+                        + " scene voxel density.");
+    }
+
     /**
      * Allocates a new solid cell with the given color and illumination.
      *
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/octree/OctreeVolumeTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/octree/OctreeVolumeTest.java
new file mode 100644 (file)
index 0000000..64bd768
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.octree;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Cell-pool behavior of {@link OctreeVolume} — most notably the
+ * exhaustion path, which used to hang in an infinite rescan loop.
+ */
+public class OctreeVolumeTest {
+
+    /**
+     * Allocating more cells than the pool holds must fail loudly
+     * ({@link IllegalStateException}), not hang. The 5s timeout fails the
+     * test on the pre-fix implementation (infinite loop).
+     */
+    @Test(timeout = 5000)
+    public void cellPoolExhaustionFailsLoudly() {
+        final OctreeVolume volume = new OctreeVolume();
+        volume.initWorld(8, 64);   // tiny pool: 8 cells
+
+        try {
+            // master cell + up to 8 more allocations — must throw by then
+            for (int i = 0; i < 16; i++)
+                volume.makeNewCell(0x808080, 0);
+            fail("expected IllegalStateException on pool exhaustion");
+        } catch (final IllegalStateException e) {
+            assertTrue("message should name the pool capacity: " + e.getMessage(),
+                    e.getMessage().contains("8"));
+        }
+    }
+
+    /** Sanity: within capacity, allocation keeps working and cells are solid. */
+    @Test(timeout = 5000)
+    public void allocationWithinCapacityWorks() {
+        final OctreeVolume volume = new OctreeVolume();
+        volume.initWorld(16, 64);
+
+        final int pointer = volume.makeNewCell(0x123456, 7);
+        assertTrue(pointer >= 0);
+        assertTrue(volume.isCellSolid(pointer));
+    }
+}