From f2160378cef554d3a2f0bcbd69f9af35f796e816 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sun, 15 Mar 2026 12:39:35 -0400 Subject: [PATCH] Fix OOM crashes during screenshot comparison - Recycle captured bitmap in createBitmapFromActivity() after saving to disk, eliminating ~9.6 MB leaked per test invocation - Switch IntBuffer allocation from heap (IntBuffer.allocate) to native memory (ByteBuffer.allocateDirect) to bypass JVM heap constraints - Add overflow guard for large capacity * INTEGER_BYTES computations - Wrap ParallelPixelProcessor analyze() and transform() in try/finally to guarantee buffer cleanup on exceptions - Recycle diff bitmap in HighContrastDiff.generate() after saving - Update ImageBufferTest to reflect direct buffer allocation behavior --- .../testify/core/processor/ImageBufferTest.kt | 11 ++- .../java/dev/testify/ScreenshotUtility.kt | 2 + .../core/exception/LowMemoryException.kt | 2 +- .../dev/testify/core/processor/ImageBuffer.kt | 11 ++- .../core/processor/ParallelPixelProcessor.kt | 74 ++++++++++--------- .../core/processor/diff/HighContrastDiff.kt | 15 ++-- 6 files changed, 68 insertions(+), 47 deletions(-) diff --git a/Library/src/androidTest/java/dev/testify/core/processor/ImageBufferTest.kt b/Library/src/androidTest/java/dev/testify/core/processor/ImageBufferTest.kt index c3cb31f67..710baa6a9 100644 --- a/Library/src/androidTest/java/dev/testify/core/processor/ImageBufferTest.kt +++ b/Library/src/androidTest/java/dev/testify/core/processor/ImageBufferTest.kt @@ -23,9 +23,6 @@ */ package dev.testify.core.processor -import android.app.ActivityManager -import android.content.Context.ACTIVITY_SERVICE -import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import dev.testify.core.exception.ImageBufferAllocationException import dev.testify.core.exception.LowMemoryException import org.junit.Assert.assertEquals @@ -69,14 +66,16 @@ class ImageBufferTest { @Test(expected = LowMemoryException::class) fun allocate_fails_on_oom() { - val activityManager = getInstrumentation().targetContext.getSystemService(ACTIVITY_SERVICE) as ActivityManager - val requestedSize: Int = activityManager.memoryClass * 1_048_576 / 2 + // Request a capacity whose byte size (capacity * 4) exceeds Int.MAX_VALUE, + // which cannot be fulfilled by a single direct ByteBuffer. + val requestedSize: Int = Int.MAX_VALUE / 2 ImageBuffers.allocate(width = 1, height = requestedSize, allocateDiffBuffer = false) } @Test fun can_allocate_a_reasonable_amount() { - val requestedSize: Int = Runtime.getRuntime().freeMemory().toInt() / 10 + // Allocate buffers equivalent to a 1080x1920 screen (a typical device resolution) + val requestedSize = 1080 * 1920 val buffers = ImageBuffers.allocate(width = 1, height = requestedSize, allocateDiffBuffer = false) assertNotNull(buffers.baselineBuffer) assertNotNull(buffers.currentBuffer) diff --git a/Library/src/main/java/dev/testify/ScreenshotUtility.kt b/Library/src/main/java/dev/testify/ScreenshotUtility.kt index bbb3a40cb..6a12182ad 100644 --- a/Library/src/main/java/dev/testify/ScreenshotUtility.kt +++ b/Library/src/main/java/dev/testify/ScreenshotUtility.kt @@ -163,6 +163,8 @@ fun createBitmapFromActivity( val destination = getDestination(activity, fileName) saveBitmapToDestination(activity, currentActivityBitmap[0], destination) + currentActivityBitmap[0]?.recycle() + currentActivityBitmap[0] = null return destination.loadBitmap(preferredBitmapOptions) } diff --git a/Library/src/main/java/dev/testify/core/exception/LowMemoryException.kt b/Library/src/main/java/dev/testify/core/exception/LowMemoryException.kt index 80b837930..f33012d54 100644 --- a/Library/src/main/java/dev/testify/core/exception/LowMemoryException.kt +++ b/Library/src/main/java/dev/testify/core/exception/LowMemoryException.kt @@ -50,7 +50,7 @@ import dev.testify.core.formatDeviceString @Suppress("ktlint:standard:argument-list-wrapping") class LowMemoryException( targetContext: Context, - requestedAllocation: Int, + requestedAllocation: Long, memoryInfo: String, cause: OutOfMemoryError ) : TestifyException( diff --git a/Library/src/main/java/dev/testify/core/processor/ImageBuffer.kt b/Library/src/main/java/dev/testify/core/processor/ImageBuffer.kt index 17d8477e1..7626dbf3c 100644 --- a/Library/src/main/java/dev/testify/core/processor/ImageBuffer.kt +++ b/Library/src/main/java/dev/testify/core/processor/ImageBuffer.kt @@ -34,6 +34,8 @@ import androidx.annotation.IntRange import androidx.test.platform.app.InstrumentationRegistry import dev.testify.core.exception.ImageBufferAllocationException import dev.testify.core.exception.LowMemoryException +import java.nio.ByteBuffer +import java.nio.ByteOrder import java.nio.IntBuffer private const val INTEGER_BYTES: Int = 4 @@ -122,11 +124,16 @@ internal data class ImageBuffers( * @throws LowMemoryException if the allocation fails twice */ internal fun allocateSafely(capacity: Int, retry: Boolean = true): IntBuffer { - val requestedBytes = capacity * INTEGER_BYTES + val requestedBytes = capacity.toLong() * INTEGER_BYTES return try { val memoryState = formatMemoryState() Log.v(LOG_TAG, "Allocating $requestedBytes bytes\n$memoryState") - IntBuffer.allocate(capacity) + if (requestedBytes > Int.MAX_VALUE) { + throw OutOfMemoryError("Requested allocation of $requestedBytes bytes exceeds maximum direct buffer size") + } + ByteBuffer.allocateDirect(requestedBytes.toInt()) + .order(ByteOrder.nativeOrder()) + .asIntBuffer() } catch (e: OutOfMemoryError) { val memoryState = formatMemoryState() Log.e(LOG_TAG, "Error allocating $requestedBytes bytes\n$memoryState", e) diff --git a/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt b/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt index da02a4547..238202686 100644 --- a/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt +++ b/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt @@ -133,23 +133,26 @@ class ParallelPixelProcessor private constructor( */ fun analyze(analyzer: AnalyzePixelFunction): Boolean { val buffers = prepareBuffers() - val chunkData = getChunkData(buffers.width, buffers.height) - val results = BitSet(chunkData.chunks).apply { set(0, chunkData.chunks) } - - runBlockingInChunks(chunkData) { chunk, index -> - val position = getPosition(index, buffers.width) - val baselinePixel = buffers.baselineBuffer[index] - val currentPixel = buffers.currentBuffer[index] - if (!analyzer(baselinePixel, currentPixel, position)) { - results.clear(chunk) - false - } else { - true + try { + val chunkData = getChunkData(buffers.width, buffers.height) + val results = BitSet(chunkData.chunks).apply { set(0, chunkData.chunks) } + + runBlockingInChunks(chunkData) { chunk, index -> + val position = getPosition(index, buffers.width) + val baselinePixel = buffers.baselineBuffer[index] + val currentPixel = buffers.currentBuffer[index] + if (!analyzer(baselinePixel, currentPixel, position)) { + results.clear(chunk) + false + } else { + true + } } - } - buffers.free() - return results.cardinality() == chunkData.chunks + return results.cardinality() == chunkData.chunks + } finally { + buffers.free() + } } /** @@ -163,25 +166,30 @@ class ParallelPixelProcessor private constructor( transformer: (baselinePixel: Int, currentPixel: Int, position: Pair) -> Int ): TransformResult { val buffers = prepareBuffers(allocateDiffBuffer = true) - val chunkData = getChunkData(buffers.width, buffers.height) - val diffBuffer = buffers.diffBuffer - - runBlockingInChunks(chunkData) { _, index -> - val position = getPosition(index, buffers.width) - val baselinePixel = buffers.baselineBuffer[index] - val currentPixel = buffers.currentBuffer[index] - diffBuffer.put(index, transformer(baselinePixel, currentPixel, position)) - true - } - - val result = TransformResult( - width = buffers.width, - height = buffers.height, - pixels = diffBuffer.array() - ) + try { + val chunkData = getChunkData(buffers.width, buffers.height) + val diffBuffer = buffers.diffBuffer + + runBlockingInChunks(chunkData) { _, index -> + val position = getPosition(index, buffers.width) + val baselinePixel = buffers.baselineBuffer[index] + val currentPixel = buffers.currentBuffer[index] + diffBuffer.put(index, transformer(baselinePixel, currentPixel, position)) + true + } - buffers.free() - return result + val pixels = IntArray(buffers.width * buffers.height) + diffBuffer.position(0) + diffBuffer.get(pixels) + + return TransformResult( + width = buffers.width, + height = buffers.height, + pixels = pixels + ) + } finally { + buffers.free() + } } /** diff --git a/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt b/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt index 664beeeab..6ac404c84 100644 --- a/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt +++ b/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt @@ -100,11 +100,16 @@ class HighContrastDiff private constructor( } } - saveBitmapToDestination( - context = context, - bitmap = transformResult.createBitmap(), - destination = getDestination(context, "$fileName.diff") - ) + val diffBitmap = transformResult.createBitmap() + try { + saveBitmapToDestination( + context = context, + bitmap = diffBitmap, + destination = getDestination(context, "$fileName.diff") + ) + } finally { + diffBitmap.recycle() + } } private var exactness: Float? = null