getPersistentResources() {
+ return persistent;
+ }
+
+ /**
+ * Format human-readable report for test failure messages.
+ * Includes allocation stacks for leaked resources if available.
+ */
+ public String formatReport() {
+ var sb = new StringBuilder();
+ sb.append("Resource Lifecycle Report\n");
+ sb.append("========================\n");
+ sb.append(String.format("Duration: %d ms\n", durationMs));
+ sb.append(String.format("Leaked: %d\n", leaked.size()));
+ sb.append(String.format("Freed: %d\n", freed.size()));
+ sb.append(String.format("Persistent: %d\n\n", persistent.size()));
+
+ if (!leaked.isEmpty()) {
+ sb.append("LEAKED RESOURCES:\n");
+
+ // Group leaks by type
+ var leakedByType = leaked.stream()
+ .collect(Collectors.groupingBy(ResourceInfo::type));
+
+ for (var entry : leakedByType.entrySet()) {
+ sb.append(String.format(" %s: %d instances\n",
+ entry.getKey(), entry.getValue().size()));
+
+ for (var info : entry.getValue()) {
+ sb.append(String.format(" - %s (age: %d ms)\n",
+ info.id(), info.ageMillis()));
+
+ if (info.allocationStack() != null) {
+ sb.append(" Allocated at:");
+ sb.append(info.allocationStack());
+ sb.append("\n");
+ }
+ }
+ }
+ }
+
+ return sb.toString();
+ }
+}
diff --git a/resource-lifecycle-testing/src/main/java/com/hellblazer/luciferase/test/lifecycle/ResourceInfo.java b/resource-lifecycle-testing/src/main/java/com/hellblazer/luciferase/test/lifecycle/ResourceInfo.java
new file mode 100644
index 0000000..10b4bf9
--- /dev/null
+++ b/resource-lifecycle-testing/src/main/java/com/hellblazer/luciferase/test/lifecycle/ResourceInfo.java
@@ -0,0 +1,34 @@
+package com.hellblazer.luciferase.test.lifecycle;
+
+import com.hellblazer.luciferase.resource.ResourceHandle;
+
+/**
+ * Immutable snapshot of resource metadata for leak analysis.
+ * Captures key information at the time of snapshot.
+ *
+ * @param id Unique resource identifier (UUID)
+ * @param type Resource type (class simple name: "CLBufferHandle")
+ * @param ageMillis Resource age in milliseconds at snapshot time
+ * @param allocationStack Allocation stack trace (nullable, debug logging only)
+ */
+public record ResourceInfo(
+ String id,
+ String type,
+ long ageMillis,
+ String allocationStack
+) {
+ /**
+ * Factory method to extract ResourceInfo from ResourceHandle.
+ *
+ * @param handle The resource handle to extract info from
+ * @return Immutable ResourceInfo snapshot
+ */
+ public static ResourceInfo from(ResourceHandle> handle) {
+ return new ResourceInfo(
+ handle.getId(),
+ handle.getClass().getSimpleName(),
+ handle.getAgeMillis(),
+ handle.getAllocationStack()
+ );
+ }
+}
diff --git a/resource-lifecycle-testing/src/main/java/com/hellblazer/luciferase/test/lifecycle/ResourceLifecycleTestSupport.java b/resource-lifecycle-testing/src/main/java/com/hellblazer/luciferase/test/lifecycle/ResourceLifecycleTestSupport.java
new file mode 100644
index 0000000..83df5f0
--- /dev/null
+++ b/resource-lifecycle-testing/src/main/java/com/hellblazer/luciferase/test/lifecycle/ResourceLifecycleTestSupport.java
@@ -0,0 +1,128 @@
+package com.hellblazer.luciferase.test.lifecycle;
+
+import com.hellblazer.luciferase.resource.ResourceTracker;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Abstract base class for resource lifecycle tests.
+ * Provides snapshot/diff/assert capabilities for detecting resource leaks.
+ *
+ * Tests extending this class can verify that components properly cleanup
+ * all allocated resources (CLBufferHandle, CLProgramHandle, etc.).
+ *
+ * Usage Pattern:
+ *
+ * var before = captureSnapshot();
+ * try (var component = new SomeComponent()) {
+ * component.doWork();
+ * }
+ * var after = captureSnapshot();
+ * var report = diff(before, after);
+ * assertNoLeaks(report);
+ *
+ */
+public abstract class ResourceLifecycleTestSupport {
+
+ protected ResourceTracker tracker;
+
+ /**
+ * Setup lifecycle testing infrastructure.
+ * Initializes tracker to global instance by default.
+ * Subclasses can override to use custom tracker.
+ */
+ @BeforeEach
+ void setupLifecycleTesting() {
+ tracker = ResourceTracker.getGlobalTracker();
+
+ // Optional: Reset tracker for test isolation
+ // tracker.reset(); // Uncomment if tests need clean slate
+ }
+
+ /**
+ * Cleanup after lifecycle testing.
+ * Forces cleanup of any remaining resources (emergency fallback).
+ */
+ @AfterEach
+ void teardownLifecycleTesting() {
+ // Emergency cleanup in case test failed before assertNoLeaks()
+ if (tracker.getActiveCount() > 0) {
+ System.err.println("WARNING: Active resources found after test - forcing cleanup");
+ forceCleanupAll();
+ }
+ }
+
+ /**
+ * Capture snapshot of current resource state.
+ *
+ * @return Immutable snapshot of active resources
+ */
+ protected ResourceSnapshot captureSnapshot() {
+ return new ResourceSnapshot(tracker);
+ }
+
+ /**
+ * Compute diff between two snapshots.
+ *
+ * @param before Snapshot before component creation
+ * @param after Snapshot after component cleanup
+ * @return LeakReport analyzing the difference
+ */
+ protected LeakReport diff(ResourceSnapshot before, ResourceSnapshot after) {
+ return LeakReport.diff(before, after);
+ }
+
+ /**
+ * Assert that no resources leaked between snapshots.
+ * Fails test with detailed report if any leaks detected.
+ *
+ * @param report The leak report to check
+ */
+ protected void assertNoLeaks(LeakReport report) {
+ if (report.hasLeaks()) {
+ fail("Resource leak detected:\n" + report.formatReport());
+ }
+ }
+
+ /**
+ * Assert exact leak count (for testing framework itself).
+ *
+ * @param report The leak report to check
+ * @param expectedLeaks Expected number of leaks
+ */
+ protected void assertLeakCount(LeakReport report, int expectedLeaks) {
+ assertEquals(expectedLeaks, report.getLeakedCount(),
+ "Expected " + expectedLeaks + " leaks:\n" + report.formatReport());
+ }
+
+ /**
+ * Assert exact freed count.
+ *
+ * @param report The leak report to check
+ * @param expectedFreed Expected number of freed resources
+ */
+ protected void assertFreedCount(LeakReport report, int expectedFreed) {
+ assertEquals(expectedFreed, report.getFreedCount(),
+ "Expected " + expectedFreed + " freed resources");
+ }
+
+ /**
+ * Force cleanup of all active resources (emergency).
+ * Use in @AfterEach to prevent leak accumulation across tests.
+ *
+ * @return Number of resources cleaned up
+ */
+ protected int forceCleanupAll() {
+ return tracker.forceCloseAll();
+ }
+
+ /**
+ * Get current active resource count.
+ * Useful for debugging test setup.
+ */
+ protected int getActiveResourceCount() {
+ return tracker.getActiveCount();
+ }
+}
diff --git a/resource-lifecycle-testing/src/main/java/com/hellblazer/luciferase/test/lifecycle/ResourceSnapshot.java b/resource-lifecycle-testing/src/main/java/com/hellblazer/luciferase/test/lifecycle/ResourceSnapshot.java
new file mode 100644
index 0000000..8750ffe
--- /dev/null
+++ b/resource-lifecycle-testing/src/main/java/com/hellblazer/luciferase/test/lifecycle/ResourceSnapshot.java
@@ -0,0 +1,103 @@
+package com.hellblazer.luciferase.test.lifecycle;
+
+import com.hellblazer.luciferase.resource.ResourceTracker;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Immutable snapshot of active resources at a point in time.
+ * Provides grouping by resource type for analysis.
+ * Thread-safe, suitable for concurrent testing.
+ */
+public final class ResourceSnapshot {
+ private final Map> resourcesByType;
+ private final long timestamp;
+ private final int totalCount;
+
+ /**
+ * Capture snapshot of all active resources from tracker.
+ * Handles race conditions gracefully (resource closed between query and lookup).
+ *
+ * @param tracker The resource tracker to query
+ */
+ public ResourceSnapshot(ResourceTracker tracker) {
+ this.timestamp = System.currentTimeMillis();
+
+ // Get snapshot of active IDs
+ var activeIds = tracker.getActiveResourceIds();
+ this.totalCount = activeIds.size();
+
+ // Build resource info map grouped by type
+ var resources = new HashMap>();
+ for (var id : activeIds) {
+ var handle = tracker.getResource(id);
+ if (handle != null) { // null check for race condition
+ var info = ResourceInfo.from(handle);
+ resources.computeIfAbsent(info.type(), k -> new ArrayList<>())
+ .add(info);
+ }
+ }
+
+ // Make immutable
+ var immutableMap = new HashMap>();
+ for (var entry : resources.entrySet()) {
+ immutableMap.put(entry.getKey(), Collections.unmodifiableList(entry.getValue()));
+ }
+ this.resourcesByType = Collections.unmodifiableMap(immutableMap);
+ }
+
+ /**
+ * Get total count of resources in snapshot.
+ */
+ public int getTotalCount() {
+ return totalCount;
+ }
+
+ /**
+ * Get timestamp when snapshot was captured.
+ */
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ /**
+ * Get all resource types present in snapshot.
+ */
+ public Set getResourceTypes() {
+ return resourcesByType.keySet();
+ }
+
+ /**
+ * Get all resources of a specific type.
+ *
+ * @param type The resource type (class simple name)
+ * @return List of resources, or empty list if type not present
+ */
+ public List getResourcesByType(String type) {
+ return resourcesByType.getOrDefault(type, Collections.emptyList());
+ }
+
+ /**
+ * Lookup specific resource by ID.
+ *
+ * @param id The resource ID
+ * @return ResourceInfo if found, empty otherwise
+ */
+ public Optional getResourceById(String id) {
+ return resourcesByType.values().stream()
+ .flatMap(List::stream)
+ .filter(info -> info.id().equals(id))
+ .findFirst();
+ }
+
+ /**
+ * Get all resource IDs in snapshot (for diff operations).
+ */
+ Set getAllResourceIds() {
+ return resourcesByType.values().stream()
+ .flatMap(List::stream)
+ .map(ResourceInfo::id)
+ .collect(Collectors.toSet());
+ }
+}
diff --git a/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/LeakReportTest.java b/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/LeakReportTest.java
new file mode 100644
index 0000000..7dd235d
--- /dev/null
+++ b/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/LeakReportTest.java
@@ -0,0 +1,267 @@
+package com.hellblazer.luciferase.test.lifecycle;
+
+import com.hellblazer.luciferase.resource.ResourceHandle;
+import com.hellblazer.luciferase.resource.ResourceTracker;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Test for LeakReport diff algorithm and formatting.
+ */
+class LeakReportTest {
+
+ @Test
+ void testNoChanges() {
+ var tracker = mock(ResourceTracker.class);
+ var handle = createMockHandle("same-id", "CLBufferHandle", 100L);
+
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("same-id"));
+ when(tracker.getResource("same-id")).thenAnswer(inv -> handle);
+
+ var before = new ResourceSnapshot(tracker);
+ var after = new ResourceSnapshot(tracker);
+
+ var report = LeakReport.diff(before, after);
+
+ assertFalse(report.hasLeaks());
+ assertEquals(0, report.getLeakedCount());
+ assertEquals(0, report.getFreedCount());
+ assertEquals(1, report.getPersistentCount());
+
+ var persistent = report.getPersistentResources();
+ assertEquals(1, persistent.size());
+ assertTrue(persistent.stream().anyMatch(r -> r.id().equals("same-id")));
+ }
+
+ @Test
+ void testLeakDetection() {
+ var tracker = mock(ResourceTracker.class);
+
+ // Before: no resources
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of());
+ var before = new ResourceSnapshot(tracker);
+
+ // After: one leaked resource
+ var leaked = createMockHandle("leaked-id", "CLBufferHandle", 50L);
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("leaked-id"));
+ when(tracker.getResource("leaked-id")).thenAnswer(inv -> leaked);
+ var after = new ResourceSnapshot(tracker);
+
+ var report = LeakReport.diff(before, after);
+
+ assertTrue(report.hasLeaks());
+ assertEquals(1, report.getLeakedCount());
+ assertEquals(0, report.getFreedCount());
+ assertEquals(0, report.getPersistentCount());
+
+ var leakedResources = report.getLeakedResources();
+ assertEquals(1, leakedResources.size());
+ assertTrue(leakedResources.stream().anyMatch(r -> r.id().equals("leaked-id")));
+ }
+
+ @Test
+ void testFreedDetection() {
+ var tracker = mock(ResourceTracker.class);
+ var freed = createMockHandle("freed-id", "CLBufferHandle", 100L);
+
+ // Before: one resource
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("freed-id"));
+ when(tracker.getResource("freed-id")).thenAnswer(inv -> freed);
+ var before = new ResourceSnapshot(tracker);
+
+ // After: resource properly closed
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of());
+ var after = new ResourceSnapshot(tracker);
+
+ var report = LeakReport.diff(before, after);
+
+ assertFalse(report.hasLeaks());
+ assertEquals(0, report.getLeakedCount());
+ assertEquals(1, report.getFreedCount());
+ assertEquals(0, report.getPersistentCount());
+
+ var freedResources = report.getFreedResources();
+ assertEquals(1, freedResources.size());
+ assertTrue(freedResources.stream().anyMatch(r -> r.id().equals("freed-id")));
+ }
+
+ @Test
+ void testPersistentResources() {
+ var tracker = mock(ResourceTracker.class);
+ var persistent1 = createMockHandle("persist-1", "CLBufferHandle", 100L);
+ var persistent2 = createMockHandle("persist-2", "CLKernelHandle", 200L);
+ var freed = createMockHandle("freed-1", "CLBufferHandle", 150L);
+ var leaked = createMockHandle("leaked-1", "CLBufferHandle", 50L);
+
+ // Before: persistent + freed
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("persist-1", "persist-2", "freed-1"));
+ when(tracker.getResource("persist-1")).thenAnswer(inv -> persistent1);
+ when(tracker.getResource("persist-2")).thenAnswer(inv -> persistent2);
+ when(tracker.getResource("freed-1")).thenAnswer(inv -> freed);
+ var before = new ResourceSnapshot(tracker);
+
+ // After: persistent + leaked
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("persist-1", "persist-2", "leaked-1"));
+ when(tracker.getResource("persist-1")).thenAnswer(inv -> persistent1);
+ when(tracker.getResource("persist-2")).thenAnswer(inv -> persistent2);
+ when(tracker.getResource("leaked-1")).thenAnswer(inv -> leaked);
+ var after = new ResourceSnapshot(tracker);
+
+ var report = LeakReport.diff(before, after);
+
+ assertTrue(report.hasLeaks());
+ assertEquals(1, report.getLeakedCount());
+ assertEquals(1, report.getFreedCount());
+ assertEquals(2, report.getPersistentCount());
+
+ assertTrue(report.getLeakedResources().stream().anyMatch(r -> r.id().equals("leaked-1")));
+ assertTrue(report.getFreedResources().stream().anyMatch(r -> r.id().equals("freed-1")));
+ assertTrue(report.getPersistentResources().stream().anyMatch(r -> r.id().equals("persist-1")));
+ assertTrue(report.getPersistentResources().stream().anyMatch(r -> r.id().equals("persist-2")));
+ }
+
+ @Test
+ void testFormatReport() {
+ var tracker = mock(ResourceTracker.class);
+
+ // Before: empty
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of());
+ var before = new ResourceSnapshot(tracker);
+
+ // After: leaked resources with stack traces
+ var leaked1 = createMockHandleWithStack("leak-1", "CLBufferHandle", 100L, "at com.example.Test.method1");
+ var leaked2 = createMockHandleWithStack("leak-2", "CLKernelHandle", 150L, "at com.example.Test.method2");
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("leak-1", "leak-2"));
+ when(tracker.getResource("leak-1")).thenAnswer(inv -> leaked1);
+ when(tracker.getResource("leak-2")).thenAnswer(inv -> leaked2);
+ var after = new ResourceSnapshot(tracker);
+
+ var report = LeakReport.diff(before, after);
+ var formatted = report.formatReport();
+
+ assertNotNull(formatted);
+ assertTrue(formatted.contains("Resource Lifecycle Report"));
+ assertTrue(formatted.contains("Leaked: 2"));
+ assertTrue(formatted.contains("Freed: 0"));
+ assertTrue(formatted.contains("Persistent: 0"));
+ assertTrue(formatted.contains("LEAKED RESOURCES"));
+ assertTrue(formatted.contains("CLBufferHandleMock"));
+ assertTrue(formatted.contains("CLKernelHandleMock"));
+ assertTrue(formatted.contains("leak-1"));
+ assertTrue(formatted.contains("leak-2"));
+ assertTrue(formatted.contains("at com.example.Test.method1"));
+ assertTrue(formatted.contains("at com.example.Test.method2"));
+ }
+
+ @Test
+ void testFormatReportNoLeaks() {
+ var tracker = mock(ResourceTracker.class);
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of());
+
+ var before = new ResourceSnapshot(tracker);
+ var after = new ResourceSnapshot(tracker);
+
+ var report = LeakReport.diff(before, after);
+ var formatted = report.formatReport();
+
+ assertNotNull(formatted);
+ assertTrue(formatted.contains("Leaked: 0"));
+ assertFalse(formatted.contains("LEAKED RESOURCES"));
+ }
+
+ @Test
+ void testMultipleLeaksGroupedByType() {
+ var tracker = mock(ResourceTracker.class);
+
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of());
+ var before = new ResourceSnapshot(tracker);
+
+ // Multiple leaks of same type
+ var leak1 = createMockHandle("buf-leak-1", "CLBufferHandle", 100L);
+ var leak2 = createMockHandle("buf-leak-2", "CLBufferHandle", 150L);
+ var leak3 = createMockHandle("kern-leak-1", "CLKernelHandle", 200L);
+
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("buf-leak-1", "buf-leak-2", "kern-leak-1"));
+ when(tracker.getResource("buf-leak-1")).thenAnswer(inv -> leak1);
+ when(tracker.getResource("buf-leak-2")).thenAnswer(inv -> leak2);
+ when(tracker.getResource("kern-leak-1")).thenAnswer(inv -> leak3);
+ var after = new ResourceSnapshot(tracker);
+
+ var report = LeakReport.diff(before, after);
+ var formatted = report.formatReport();
+
+ assertTrue(formatted.contains("CLBufferHandleMock: 2 instances"));
+ assertTrue(formatted.contains("CLKernelHandleMock: 1 instances"));
+ }
+
+ private ResourceHandle> createMockHandle(String id, String type, long ageMillis) {
+ return createMockHandleWithStack(id, type, ageMillis, null);
+ }
+
+ private ResourceHandle> createMockHandleWithStack(String id, String type, long ageMillis, String stack) {
+ if ("CLBufferHandle".equals(type)) {
+ return new CLBufferHandleMock(id, ageMillis, stack);
+ } else if ("CLKernelHandle".equals(type)) {
+ return new CLKernelHandleMock(id, ageMillis, stack);
+ }
+ return new TestHandle(id, ageMillis, stack);
+ }
+
+ private static class CLBufferHandleMock extends ResourceHandle {
+ private final String testId;
+ private final long testAge;
+ private final String testStack;
+
+ protected CLBufferHandleMock(String id, long age, String stack) {
+ super(0L, null);
+ this.testId = id;
+ this.testAge = age;
+ this.testStack = stack;
+ }
+
+ @Override public String getId() { return testId; }
+ @Override public long getAgeMillis() { return testAge; }
+ @Override public String getAllocationStack() { return testStack; }
+ @Override protected void doCleanup(Long resource) {}
+ }
+
+ private static class CLKernelHandleMock extends ResourceHandle {
+ private final String testId;
+ private final long testAge;
+ private final String testStack;
+
+ protected CLKernelHandleMock(String id, long age, String stack) {
+ super(0L, null);
+ this.testId = id;
+ this.testAge = age;
+ this.testStack = stack;
+ }
+
+ @Override public String getId() { return testId; }
+ @Override public long getAgeMillis() { return testAge; }
+ @Override public String getAllocationStack() { return testStack; }
+ @Override protected void doCleanup(Long resource) {}
+ }
+
+ private static class TestHandle extends ResourceHandle {
+ private final String testId;
+ private final long testAge;
+ private final String testStack;
+
+ protected TestHandle(String id, long age, String stack) {
+ super(0L, null);
+ this.testId = id;
+ this.testAge = age;
+ this.testStack = stack;
+ }
+
+ @Override public String getId() { return testId; }
+ @Override public long getAgeMillis() { return testAge; }
+ @Override public String getAllocationStack() { return testStack; }
+ @Override protected void doCleanup(Long resource) {}
+ }
+}
diff --git a/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceInfoTest.java b/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceInfoTest.java
new file mode 100644
index 0000000..2b0e51f
--- /dev/null
+++ b/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceInfoTest.java
@@ -0,0 +1,81 @@
+package com.hellblazer.luciferase.test.lifecycle;
+
+import com.hellblazer.luciferase.resource.ResourceHandle;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Test for ResourceInfo record contract and factory method.
+ */
+class ResourceInfoTest {
+
+ @Test
+ void testRecordImmutability() {
+ var info = new ResourceInfo("id-123", "CLBufferHandle", 100L, "stack trace");
+
+ assertEquals("id-123", info.id());
+ assertEquals("CLBufferHandle", info.type());
+ assertEquals(100L, info.ageMillis());
+ assertEquals("stack trace", info.allocationStack());
+ }
+
+ @Test
+ void testEqualsAndHashCode() {
+ var info1 = new ResourceInfo("id-123", "CLBufferHandle", 100L, "stack");
+ var info2 = new ResourceInfo("id-123", "CLBufferHandle", 100L, "stack");
+ var info3 = new ResourceInfo("id-456", "CLBufferHandle", 100L, "stack");
+
+ // Same values should be equal
+ assertEquals(info1, info2);
+ assertEquals(info1.hashCode(), info2.hashCode());
+
+ // Different IDs should not be equal
+ assertNotEquals(info1, info3);
+ }
+
+ @Test
+ void testFromFactory() {
+ // Create real ResourceHandle with test data
+ var testHandle = new TestResourceHandle("test-id-789", 250L, "test stack trace");
+
+ // Extract info using factory method
+ var info = ResourceInfo.from(testHandle);
+
+ assertEquals("test-id-789", info.id());
+ assertEquals("TestResourceHandle", info.type());
+ assertEquals(250L, info.ageMillis());
+ assertEquals("test stack trace", info.allocationStack());
+ }
+
+ @Test
+ void testNullAllocationStack() {
+ // Create handle with null allocation stack (debug logging disabled)
+ var testHandle = new TestResourceHandle("id-null-stack", 50L, null);
+
+ var info = ResourceInfo.from(testHandle);
+
+ assertEquals("id-null-stack", info.id());
+ assertNull(info.allocationStack());
+ }
+
+ // Test helper class with controllable properties
+ private static class TestResourceHandle extends ResourceHandle {
+ private final String testId;
+ private final long testAge;
+ private final String testStack;
+
+ protected TestResourceHandle(String id, long age, String stack) {
+ super(0L, null);
+ this.testId = id;
+ this.testAge = age;
+ this.testStack = stack;
+ }
+
+ @Override public String getId() { return testId; }
+ @Override public long getAgeMillis() { return testAge; }
+ @Override public String getAllocationStack() { return testStack; }
+ @Override protected void doCleanup(Long resource) {}
+ }
+}
diff --git a/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceLifecycleIntegrationTest.java b/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceLifecycleIntegrationTest.java
new file mode 100644
index 0000000..54f7592
--- /dev/null
+++ b/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceLifecycleIntegrationTest.java
@@ -0,0 +1,209 @@
+package com.hellblazer.luciferase.test.lifecycle;
+
+import com.hellblazer.luciferase.resource.ResourceHandle;
+import com.hellblazer.luciferase.resource.ResourceTracker;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration test for ResourceLifecycle framework with real ResourceTracker.
+ * Validates end-to-end workflow with actual ResourceHandle instances.
+ */
+class ResourceLifecycleIntegrationTest {
+
+ @Test
+ void testWithRealTracker() {
+ // Use global ResourceTracker, real ResourceHandle implementation
+ var tracker = ResourceTracker.getGlobalTracker();
+
+ // Create real ResourceHandle BEFORE taking snapshot
+ var handle = new TestResourceHandle();
+
+ var before = new ResourceSnapshot(tracker);
+
+ // Now close the handle
+ handle.close();
+
+ var after = new ResourceSnapshot(tracker);
+ var report = LeakReport.diff(before, after);
+
+ assertFalse(report.hasLeaks());
+ assertEquals(1, report.getFreedCount());
+ assertEquals(0, report.getLeakedCount());
+ }
+
+ @Test
+ void testMultipleResources() {
+ var tracker = ResourceTracker.getGlobalTracker();
+
+ // Create multiple resources BEFORE snapshot
+ var h1 = new TestResourceHandle();
+ var h2 = new TestResourceHandle();
+
+ var before = new ResourceSnapshot(tracker);
+
+ // Close them
+ h1.close();
+ h2.close();
+
+ var after = new ResourceSnapshot(tracker);
+ var report = LeakReport.diff(before, after);
+
+ assertFalse(report.hasLeaks());
+ assertEquals(2, report.getFreedCount());
+ }
+
+ @Test
+ void testMixedLifecycles() {
+ var tracker = ResourceTracker.getGlobalTracker();
+ var before = new ResourceSnapshot(tracker);
+
+ // Create persistent resource (stays alive)
+ var persistent = new TestResourceHandle();
+
+ // Create resource to be freed
+ var freed = new TestResourceHandle();
+
+ var middle = new ResourceSnapshot(tracker);
+
+ // Create leaked resource (not closed)
+ var leaked = new TestResourceHandle();
+
+ // Now close the freed resource
+ freed.close();
+
+ var after = new ResourceSnapshot(tracker);
+
+ // Compare before to after
+ var report = LeakReport.diff(before, after);
+
+ assertTrue(report.hasLeaks());
+ assertEquals(2, report.getLeakedCount()); // persistent + leaked
+ assertEquals(0, report.getFreedCount()); // freed not in before
+
+ // Compare middle to after (only new resources)
+ var reportFromMiddle = LeakReport.diff(middle, after);
+ assertEquals(1, reportFromMiddle.getLeakedCount()); // just leaked
+ assertEquals(1, reportFromMiddle.getFreedCount()); // freed
+ assertEquals(1, reportFromMiddle.getPersistentCount()); // persistent
+
+ // Cleanup
+ persistent.close();
+ leaked.close();
+
+ var cleanup = new ResourceSnapshot(tracker);
+ var cleanupReport = LeakReport.diff(after, cleanup);
+ assertEquals(0, cleanupReport.getLeakedCount());
+ assertEquals(2, cleanupReport.getFreedCount());
+ }
+
+ @Test
+ void testResourceTypes() {
+ var tracker = ResourceTracker.getGlobalTracker();
+
+ // Create resources BEFORE snapshot
+ var h1 = new TestResourceHandle();
+ var h2 = new AlternateResourceHandle();
+
+ var before = new ResourceSnapshot(tracker);
+
+ assertTrue(before.getResourceTypes().contains("TestResourceHandle"));
+ assertTrue(before.getResourceTypes().contains("AlternateResourceHandle"));
+
+ var buffers = before.getResourcesByType("TestResourceHandle");
+ assertEquals(1, buffers.size());
+
+ var alternates = before.getResourcesByType("AlternateResourceHandle");
+ assertEquals(1, alternates.size());
+
+ // Close resources
+ h1.close();
+ h2.close();
+
+ var after = new ResourceSnapshot(tracker);
+ var report = LeakReport.diff(before, after);
+
+ assertFalse(report.hasLeaks());
+ assertEquals(2, report.getFreedCount());
+ }
+
+ @Test
+ void testReportFormatting() {
+ var tracker = ResourceTracker.getGlobalTracker();
+ var before = new ResourceSnapshot(tracker);
+
+ // Create leaks
+ new TestResourceHandle();
+ new TestResourceHandle();
+ new AlternateResourceHandle();
+
+ var after = new ResourceSnapshot(tracker);
+ var report = LeakReport.diff(before, after);
+
+ var formatted = report.formatReport();
+
+ assertNotNull(formatted);
+ assertTrue(formatted.contains("LEAKED RESOURCES"));
+ assertTrue(formatted.contains("TestResourceHandle: 2 instances"));
+ assertTrue(formatted.contains("AlternateResourceHandle: 1 instances"));
+
+ // Cleanup
+ tracker.forceCloseAll();
+ }
+
+ @Test
+ void testWithResourceLifecycleTestSupport() {
+ var test = new IntegrationLifecycleTest();
+ test.setupLifecycleTesting();
+
+ // Create handle BEFORE snapshot
+ var handle = new TestResourceHandle();
+
+ var before = test.captureSnapshot();
+
+ // Close it
+ handle.close();
+
+ var after = test.captureSnapshot();
+ var report = test.diff(before, after);
+
+ test.assertNoLeaks(report);
+ test.assertFreedCount(report, 1);
+
+ test.teardownLifecycleTesting();
+ }
+
+ // Test helper classes
+ private static class TestResourceHandle extends ResourceHandle {
+ private static final AtomicLong counter = new AtomicLong(0);
+
+ TestResourceHandle() {
+ super(counter.incrementAndGet(), ResourceTracker.getGlobalTracker());
+ }
+
+ @Override
+ protected void doCleanup(Long resource) {
+ // Trivial cleanup (no actual native resource)
+ }
+ }
+
+ private static class AlternateResourceHandle extends ResourceHandle {
+ private static final AtomicLong counter = new AtomicLong(1000);
+
+ AlternateResourceHandle() {
+ super(counter.incrementAndGet(), ResourceTracker.getGlobalTracker());
+ }
+
+ @Override
+ protected void doCleanup(Long resource) {
+ // Trivial cleanup
+ }
+ }
+
+ private static class IntegrationLifecycleTest extends ResourceLifecycleTestSupport {
+ // Concrete test class for integration testing
+ }
+}
diff --git a/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceLifecycleTestSupportTest.java b/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceLifecycleTestSupportTest.java
new file mode 100644
index 0000000..da6d0b3
--- /dev/null
+++ b/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceLifecycleTestSupportTest.java
@@ -0,0 +1,220 @@
+package com.hellblazer.luciferase.test.lifecycle;
+
+import com.hellblazer.luciferase.resource.ResourceHandle;
+import com.hellblazer.luciferase.resource.ResourceTracker;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Test for ResourceLifecycleTestSupport base class contract.
+ * Uses concrete test class to validate abstract base functionality.
+ */
+class ResourceLifecycleTestSupportTest {
+
+ @Test
+ void testNoLeaksWhenClean() {
+ var test = new ConcreteLifecycleTest();
+ test.setupLifecycleTesting();
+
+ var before = test.captureSnapshot();
+ // No resource creation
+ var after = test.captureSnapshot();
+ var report = test.diff(before, after);
+
+ test.assertNoLeaks(report); // Should pass
+
+ test.teardownLifecycleTesting();
+ }
+
+ @Test
+ void testLeakDetectionFails() {
+ var test = new ConcreteLifecycleTest();
+ test.setupLifecycleTesting();
+
+ var before = test.captureSnapshot();
+
+ // Create resource without closing (intentional leak)
+ var leaked = new TestResourceHandle();
+
+ var after = test.captureSnapshot();
+ var report = test.diff(before, after);
+
+ // assertNoLeaks should throw AssertionError
+ var error = assertThrows(AssertionError.class, () -> test.assertNoLeaks(report));
+ assertTrue(error.getMessage().contains("Resource leak detected"));
+
+ // Cleanup the leaked resource
+ leaked.close();
+ test.teardownLifecycleTesting();
+ }
+
+ @Test
+ void testFreedResourcesPass() {
+ var test = new ConcreteLifecycleTest();
+ test.setupLifecycleTesting();
+
+ // Create resource BEFORE snapshot
+ var resource = new TestResourceHandle();
+
+ var before = test.captureSnapshot();
+
+ // Close it
+ resource.close();
+
+ var after = test.captureSnapshot();
+ var report = test.diff(before, after);
+
+ test.assertNoLeaks(report); // Should pass (freed correctly)
+ assertEquals(1, report.getFreedCount());
+
+ test.teardownLifecycleTesting();
+ }
+
+ @Test
+ void testForceCleanup() {
+ var test = new ConcreteLifecycleTest();
+ test.setupLifecycleTesting();
+
+ // Create resources without closing
+ new TestResourceHandle();
+ new TestResourceHandle();
+
+ assertTrue(test.getActiveResourceCount() >= 2);
+
+ int cleaned = test.forceCleanupAll();
+ assertTrue(cleaned >= 2);
+
+ test.teardownLifecycleTesting();
+ }
+
+ @Test
+ void testAssertLeakCount() {
+ var test = new ConcreteLifecycleTest();
+ test.setupLifecycleTesting();
+
+ var before = test.captureSnapshot();
+
+ // Create exactly 2 leaks
+ new TestResourceHandle();
+ new TestResourceHandle();
+
+ var after = test.captureSnapshot();
+ var report = test.diff(before, after);
+
+ test.assertLeakCount(report, 2); // Should pass
+
+ // Wrong count should fail
+ assertThrows(AssertionError.class, () -> test.assertLeakCount(report, 1));
+
+ test.forceCleanupAll();
+ test.teardownLifecycleTesting();
+ }
+
+ @Test
+ void testAssertFreedCount() {
+ var test = new ConcreteLifecycleTest();
+ test.setupLifecycleTesting();
+
+ // Create 3 resources BEFORE snapshot
+ var r1 = new TestResourceHandle();
+ var r2 = new TestResourceHandle();
+ var r3 = new TestResourceHandle();
+
+ var before = test.captureSnapshot();
+
+ // Close them
+ r1.close();
+ r2.close();
+ r3.close();
+
+ var after = test.captureSnapshot();
+ var report = test.diff(before, after);
+
+ test.assertFreedCount(report, 3); // Should pass
+
+ // Wrong count should fail
+ assertThrows(AssertionError.class, () -> test.assertFreedCount(report, 2));
+
+ test.teardownLifecycleTesting();
+ }
+
+ @Test
+ void testEmergencyCleanupInTeardown() {
+ var test = new ConcreteLifecycleTest();
+ test.setupLifecycleTesting();
+
+ // Create leak (don't close)
+ new TestResourceHandle();
+
+ assertTrue(test.getActiveResourceCount() > 0);
+
+ // teardown should force cleanup
+ test.teardownLifecycleTesting();
+
+ // After teardown, resources should be cleaned
+ assertEquals(0, test.getActiveResourceCount());
+ }
+
+ @Test
+ void testMultipleSnapshots() {
+ var test = new ConcreteLifecycleTest();
+ test.setupLifecycleTesting();
+
+ // Create first resource
+ var r1 = new TestResourceHandle();
+
+ var snap1 = test.captureSnapshot();
+
+ // Create second resource
+ var r2 = new TestResourceHandle();
+
+ var snap2 = test.captureSnapshot();
+
+ // Between snap1 and snap2: 1 leaked
+ var report1 = test.diff(snap1, snap2);
+ assertEquals(1, report1.getLeakedCount());
+
+ // Close r2
+ r2.close();
+
+ var snap3 = test.captureSnapshot();
+
+ // Between snap2 and snap3: 1 freed
+ var report2 = test.diff(snap2, snap3);
+ assertEquals(1, report2.getFreedCount());
+
+ // Close r1
+ r1.close();
+
+ var snap4 = test.captureSnapshot();
+
+ // Between snap1 and snap4: r1 freed (r2 never in snap1)
+ var finalReport = test.diff(snap1, snap4);
+ assertEquals(0, finalReport.getLeakedCount());
+ assertEquals(1, finalReport.getFreedCount());
+
+ test.teardownLifecycleTesting();
+ }
+
+ // Concrete test class extending abstract base
+ private static class ConcreteLifecycleTest extends ResourceLifecycleTestSupport {
+ // Inherits all functionality from base class
+ }
+
+ // Test helper resource handle
+ private static class TestResourceHandle extends ResourceHandle {
+ private static final AtomicLong counter = new AtomicLong(0);
+
+ TestResourceHandle() {
+ super(counter.incrementAndGet(), ResourceTracker.getGlobalTracker());
+ }
+
+ @Override
+ protected void doCleanup(Long resource) {
+ // No-op for testing
+ }
+ }
+}
diff --git a/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceSnapshotTest.java b/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceSnapshotTest.java
new file mode 100644
index 0000000..f9e854e
--- /dev/null
+++ b/resource-lifecycle-testing/src/test/java/com/hellblazer/luciferase/test/lifecycle/ResourceSnapshotTest.java
@@ -0,0 +1,197 @@
+package com.hellblazer.luciferase.test.lifecycle;
+
+import com.hellblazer.luciferase.resource.ResourceHandle;
+import com.hellblazer.luciferase.resource.ResourceTracker;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Test for ResourceSnapshot capture and query capabilities.
+ */
+class ResourceSnapshotTest {
+
+ @Test
+ void testEmptySnapshot() {
+ var tracker = mock(ResourceTracker.class);
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of());
+
+ var snapshot = new ResourceSnapshot(tracker);
+
+ assertEquals(0, snapshot.getTotalCount());
+ assertTrue(snapshot.getResourceTypes().isEmpty());
+ assertTrue(snapshot.getAllResourceIds().isEmpty());
+ }
+
+ @Test
+ void testSnapshotCapture() {
+ var tracker = mock(ResourceTracker.class);
+ var handle1 = createMockHandle("id-1", "CLBufferHandle", 100L);
+ var handle2 = createMockHandle("id-2", "CLKernelHandle", 200L);
+
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("id-1", "id-2"));
+ when(tracker.getResource("id-1")).thenAnswer(inv -> handle1);
+ when(tracker.getResource("id-2")).thenAnswer(inv -> handle2);
+
+ var snapshot = new ResourceSnapshot(tracker);
+
+ assertEquals(2, snapshot.getTotalCount());
+ assertEquals(2, snapshot.getResourceTypes().size());
+ assertTrue(snapshot.getResourceTypes().contains("CLBufferHandleMock"));
+ assertTrue(snapshot.getResourceTypes().contains("CLKernelHandleMock"));
+ }
+
+ @Test
+ void testGroupingByType() {
+ var tracker = mock(ResourceTracker.class);
+ var buffer1 = createMockHandle("buf-1", "CLBufferHandle", 100L);
+ var buffer2 = createMockHandle("buf-2", "CLBufferHandle", 150L);
+ var kernel1 = createMockHandle("kern-1", "CLKernelHandle", 200L);
+
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("buf-1", "buf-2", "kern-1"));
+ when(tracker.getResource("buf-1")).thenAnswer(inv -> buffer1);
+ when(tracker.getResource("buf-2")).thenAnswer(inv -> buffer2);
+ when(tracker.getResource("kern-1")).thenAnswer(inv -> kernel1);
+
+ var snapshot = new ResourceSnapshot(tracker);
+
+ assertEquals(3, snapshot.getTotalCount());
+
+ var buffers = snapshot.getResourcesByType("CLBufferHandleMock");
+ assertEquals(2, buffers.size());
+ assertTrue(buffers.stream().anyMatch(r -> r.id().equals("buf-1")));
+ assertTrue(buffers.stream().anyMatch(r -> r.id().equals("buf-2")));
+
+ var kernels = snapshot.getResourcesByType("CLKernelHandleMock");
+ assertEquals(1, kernels.size());
+ assertEquals("kern-1", kernels.get(0).id());
+ }
+
+ @Test
+ void testResourceQueries() {
+ var tracker = mock(ResourceTracker.class);
+ var handle = createMockHandle("query-id", "CLBufferHandle", 300L);
+
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("query-id"));
+ when(tracker.getResource("query-id")).thenAnswer(inv -> handle);
+
+ var snapshot = new ResourceSnapshot(tracker);
+
+ // Test getResourcesByType
+ var byType = snapshot.getResourcesByType("CLBufferHandleMock");
+ assertEquals(1, byType.size());
+ assertEquals("query-id", byType.get(0).id());
+
+ // Test getResourceById
+ var byId = snapshot.getResourceById("query-id");
+ assertTrue(byId.isPresent());
+ assertEquals("query-id", byId.get().id());
+ assertEquals(300L, byId.get().ageMillis());
+
+ // Test non-existent ID
+ var notFound = snapshot.getResourceById("non-existent");
+ assertFalse(notFound.isPresent());
+
+ // Test non-existent type
+ var noType = snapshot.getResourcesByType("NonExistentType");
+ assertTrue(noType.isEmpty());
+ }
+
+ @Test
+ void testRaceConditionHandling() {
+ // Simulate race condition: resource closed between getActiveResourceIds() and getResource(id)
+ var tracker = mock(ResourceTracker.class);
+ var handle = createMockHandle("race-id", "CLBufferHandle", 100L);
+
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("race-id", "closed-id"));
+ when(tracker.getResource("race-id")).thenAnswer(inv -> handle);
+ when(tracker.getResource("closed-id")).thenAnswer(inv -> null); // Closed during snapshot
+
+ var snapshot = new ResourceSnapshot(tracker);
+
+ // Should only capture the non-null resource
+ assertEquals(2, snapshot.getTotalCount()); // Total count reflects initial query
+ assertEquals(1, snapshot.getAllResourceIds().size()); // But only 1 resource captured
+ assertTrue(snapshot.getResourceById("race-id").isPresent());
+ assertFalse(snapshot.getResourceById("closed-id").isPresent());
+ }
+
+ @Test
+ void testSnapshotImmutability() {
+ var tracker = mock(ResourceTracker.class);
+ var handle = createMockHandle("immut-id", "CLBufferHandle", 100L);
+
+ when(tracker.getActiveResourceIds()).thenReturn(Set.of("immut-id"));
+ when(tracker.getResource("immut-id")).thenAnswer(inv -> handle);
+
+ var snapshot = new ResourceSnapshot(tracker);
+
+ // Attempt to modify returned collections should fail or have no effect
+ var types = snapshot.getResourceTypes();
+ assertThrows(UnsupportedOperationException.class, () -> types.add("NewType"));
+
+ var byType = snapshot.getResourcesByType("CLBufferHandleMock");
+ assertThrows(UnsupportedOperationException.class, () -> byType.add(
+ new ResourceInfo("fake", "Fake", 0L, null)
+ ));
+ }
+
+ private ResourceHandle> createMockHandle(String id, String type, long ageMillis) {
+ // Use actual ResourceHandle subclasses instead of mocking
+ if ("CLBufferHandle".equals(type)) {
+ return new CLBufferHandleMock(id, ageMillis);
+ } else if ("CLKernelHandle".equals(type)) {
+ return new CLKernelHandleMock(id, ageMillis);
+ }
+ return new TestHandle(id, ageMillis);
+ }
+
+ // Test helper classes with controllable ID and age
+ private static class CLBufferHandleMock extends ResourceHandle {
+ private final String testId;
+ private final long testAge;
+
+ protected CLBufferHandleMock(String id, long age) {
+ super(0L, null);
+ this.testId = id;
+ this.testAge = age;
+ }
+
+ @Override public String getId() { return testId; }
+ @Override public long getAgeMillis() { return testAge; }
+ @Override protected void doCleanup(Long resource) {}
+ }
+
+ private static class CLKernelHandleMock extends ResourceHandle {
+ private final String testId;
+ private final long testAge;
+
+ protected CLKernelHandleMock(String id, long age) {
+ super(0L, null);
+ this.testId = id;
+ this.testAge = age;
+ }
+
+ @Override public String getId() { return testId; }
+ @Override public long getAgeMillis() { return testAge; }
+ @Override protected void doCleanup(Long resource) {}
+ }
+
+ private static class TestHandle extends ResourceHandle {
+ private final String testId;
+ private final long testAge;
+
+ protected TestHandle(String id, long age) {
+ super(0L, null);
+ this.testId = id;
+ this.testAge = age;
+ }
+
+ @Override public String getId() { return testId; }
+ @Override public long getAgeMillis() { return testAge; }
+ @Override protected void doCleanup(Long resource) {}
+ }
+}