paparazzi: Getting OutOfMemoryErrors after ~500 screenshots

Description

In our project we’re using a combination of Showkase and Paparazzi to automatically generate screenshot tests for our components. Everything was working fine until quite recently, when we noticed at around 500 screenshots the record and verification tests were failing with OOMs.

Steps to Reproduce You can use this branch in our repo experiment/screenshots-oom, then run either recordPaparazziDebug or verifyPaparazziDebug.

If you run it with logs, you’ll be able to see some logs like:

io.element.android.tests.uitests.ScreenshotTest > preview_tests[io.element.android.libraries.designsystem.theme.components_null_TextFields_TextFieldDarkPreview_0_null,NEXUS_5,1.0,en] STANDARD_OUT
    maxMemory  : 536870912
    totalMemory: 330301440

...

io.element.android.tests.uitests.ScreenshotTest > preview_tests_screens_light[preview] STANDARD_OUT
    maxMemory  : 536870912
    totalMemory: 536870912
io.element.android.tests.uitests.ScreenshotTest > preview_tests_screens_light[preview] STANDARD_ERROR
    Jun 01, 2023 9:48:06 AM app.cash.paparazzi.internal.PaparazziLogger logAndroidFramework
    INFO: Settings [3]: Can't get key animator_duration_scale from content://settings/global
    Jun 01, 2023 9:48:07 AM app.cash.paparazzi.internal.PaparazziLogger error
    SEVERE: broken: Failed executing Choreographer#doFrame
    java.lang.OutOfMemoryError: Java heap space
    	at java.desktop/java.awt.image.DataBufferInt.<init>(DataBufferInt.java:75)
    	at java.desktop/java.awt.image.Raster.createPackedRaster(Raster.java:539)
    	at java.desktop/java.awt.image.DirectColorModel.createCompatibleWritableRaster(DirectColorModel.java:1032)
    	at java.desktop/java.awt.image.BufferedImage.<init>(BufferedImage.java:333)
    	at app.cash.paparazzi.internal.ImageUtils.scale(ImageUtils.kt:238)
    	at app.cash.paparazzi.Paparazzi.scaleImage(Paparazzi.kt:412)
    	at app.cash.paparazzi.Paparazzi.access$scaleImage(Paparazzi.kt:83)
    	at app.cash.paparazzi.Paparazzi$takeSnapshots$1$2.invoke(Paparazzi.kt:325)
    	at app.cash.paparazzi.Paparazzi$takeSnapshots$1$2.invoke(Paparazzi.kt:312)
    	at app.cash.paparazzi.Paparazzi.withTime(Paparazzi.kt:360)
    	at app.cash.paparazzi.Paparazzi.takeSnapshots(Paparazzi.kt:312)
    	at app.cash.paparazzi.Paparazzi.snapshot(Paparazzi.kt:206)
    	at app.cash.paparazzi.Paparazzi.snapshot$default(Paparazzi.kt:205)
    	at app.cash.paparazzi.Paparazzi.snapshot(Paparazzi.kt:201)
    	at app.cash.paparazzi.Paparazzi.snapshot$default(Paparazzi.kt:197)
    	at io.element.android.tests.uitests.ScreenshotTestKt.screenshotTest(ScreenshotTest.kt:176)
    	at io.element.android.tests.uitests.ScreenshotTestKt.access$screenshotTest(ScreenshotTest.kt:1)
    	at io.element.android.tests.uitests.ScreenshotTest.preview_tests_screens_light(ScreenshotTest.kt:121)
    	at jdk.internal.reflect.GeneratedMethodAccessor30.invoke(Unknown Source)
    	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    	at java.base/java.lang.reflect.Method.invoke(Method.java:568)
    	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
    	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
    	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
    	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
    	at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
    	at app.cash.paparazzi.Paparazzi$apply$statement$1.evaluate(Paparazzi.kt:125)
    	at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
    	at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
    	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
    	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
    	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)

   io.element.android.tests.uitests.ScreenshotTest > preview_tests_screens_light[preview] FAILED
    java.lang.OutOfMemoryError: Java heap space: failed reallocation of scalar replaced objects

And every test adds a couple of MBs to the totalMemory value until the maxMemory is reached.

We tried removing everything that’s not ‘standard’ from the tests, even tried moving from TestParameterInjector to Parameterized runners in case this was the source of the issue, but the issue persisted.

I’d upload a heap dump, but that’s quite big, so it’s better to just run the tests to generate one locally by running either paparazzi task.

Expected behavior Tests should run with no memory being retained in each run.

Additional information:

  • Paparazzi Version: 1.3.0
  • OS: MacOS 13.4, but it was reproduced in a Linux machine too.
  • Compile SDK: 33
  • Gradle Version: 8.1
  • Android Gradle Plugin Version: 8.0.1

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Reactions: 7
  • Comments: 18 (2 by maintainers)

Most upvoted comments

@jmartinesp After conducting some investigations, I identified it is related to the memory leak of context in BridgeContentResolver from animationScale map in WindowRecomposer.android.kt.

In your instance where lots of test cases are executed within same Paparazzi instance, an excessive accumulation of AndroidComposeView occurs, leading to the occurrence of memory leak.

An issue has been filed to the layoutlib team.

cc @jrodbx

This fix works around the memory leak issue, tested on kotlin 1.9.10 This involves a couple of steps:

  1. Wrap Paparazzi in your own rule, and use a RunRules chain to run PaparazziCleanupRule at the end.
  2. Add PaparazziCleanupRule using a reflection hack to retrieve the private bridgeRenderSession object.
  3. Add the extension function that hacks around the RenderSession object.

TestRule wrapper

class PaparazziRule internal constructor(
    private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
    val paparazzi: Paparazzi,
) : TestRule {

    override fun apply(base: Statement, description: Description): Statement =
 RunRules(
            base,
            listOf(MainTestWatcher(testDispatcher), PaparazziCleanupRule(paparazzi), paparazzi),
            description,
        )
 // ** wrapper test rule code including snapshot functions calling into nested paparazzi
}

## Parazzi Reflection Hack
private class PaparazziCleanupRule(
        private val paparazzi: Paparazzi,
    ) : TestWatcher() {

        override fun finished(description: Description?) {
            super.finished(description)
            @Suppress("UNCHECKED_CAST")
            val renderSession: RenderSession = (
                paparazzi::class.memberProperties
                    .first { it.name == "bridgeRenderSession" } as KProperty1<Paparazzi, RenderSession>
                )
                .apply { isAccessible = true }
                .invoke(paparazzi)
            renderSession.disposeHack()
        }
    }

Rendersession Hack

package **

import androidx.compose.ui.platform.AndroidUiDispatcher
import androidx.compose.ui.platform.ComposeView
import app.cash.paparazzi.internal.ComposeViewAdapter
import com.android.ide.common.rendering.api.RenderSession
import com.android.ide.common.rendering.api.ViewInfo
import **.Logger
import java.lang.ref.WeakReference
import java.lang.reflect.InvocationTargetException
import java.util.concurrent.atomic.AtomicReference

private const val WINDOW_RECOMPOSER_ANDROID_KT_FQN =
    "androidx.compose.ui.platform.WindowRecomposer_androidKt"
private const val COMBINED_CONTEXT_FQN = "kotlin.coroutines.CombinedContext"
private const val TAG = "DisposeRenderSession"
private const val SNAPSHOT_KT_FQN = "androidx.compose.runtime.snapshots.SnapshotKt"

/**
 * Initiates a custom [RenderSession] disposal, involving clearing several static collections
 * including some Compose-related objects as well as executing default [RenderSession.dispose].
 */
fun RenderSession.disposeHack() {
    val applyObserversRef = AtomicReference<WeakReference<MutableCollection<*>?>?>(null)
    val toRunTrampolinedRef = AtomicReference<WeakReference<MutableCollection<*>?>?>(null)

    try {
        val windowRecomposer: Class<*> = Class.forName(WINDOW_RECOMPOSER_ANDROID_KT_FQN)
        val animationScaleField = windowRecomposer.getDeclaredField("animationScale")
        animationScaleField.isAccessible = true
        val animationScale = animationScaleField[windowRecomposer]
        if (animationScale is Map<*, *>) {
            (animationScale as MutableMap<*, *>).clear()
        }
    } catch (ex: ReflectiveOperationException) {
        // If the WindowRecomposer does not exist or the animationScale does not exist anymore,
        // ignore.
        Logger.warning(TAG, "Unable to dispose the recompose animationScale $ex")
    }
    applyObserversRef.set(WeakReference(findApplyObservers()))
    toRunTrampolinedRef.set(WeakReference(findToRunTrampolined()))

    execute {
        rootViews
            .filterNotNull()
            .forEach { v -> disposeIfCompose(v) }
    }
    val weakApplyObservers = applyObserversRef.get()
    if (weakApplyObservers != null) {
        val applyObservers = weakApplyObservers.get()
        applyObservers?.clear()
    }
    val weakToRunTrampolined = toRunTrampolinedRef.get()
    if (weakToRunTrampolined != null) {
        val toRunTrampolined = weakToRunTrampolined.get()
        toRunTrampolined?.clear()
    }
    dispose()
}

/**
 * Performs dispose() call against View object associated with [ViewInfo] if that object is an
 * instance of [ComposeViewAdapter]
 *
 * @param viewInfo a [ViewInfo] associated with the View object to be potentially disposed of
 */
private fun disposeIfCompose(viewInfo: ViewInfo) {
    val viewObject: Any? = viewInfo.viewObject
    if (viewObject !is ComposeViewAdapter) {
        return
    }
    try {
        val composeView = viewInfo.children[0].viewObject as ComposeView
        composeView.disposeComposition()
    } catch (ex: IllegalAccessException) {
        Logger.warning(TAG, "Unexpected error while disposing compose view $ex")
    } catch (ex: InvocationTargetException) {
        Logger.warning(TAG, "Unexpected error while disposing compose view $ex")
    }
}

private fun findApplyObservers(): MutableCollection<*>? {
    try {
        val applyObserversField = Class.forName(SNAPSHOT_KT_FQN).getDeclaredField("applyObservers")
        applyObserversField.isAccessible = true
        val applyObservers = applyObserversField[null]
        if (applyObservers is MutableCollection<*>) {
            return applyObservers
        }
        Logger.warning(TAG, "SnapshotsKt.applyObservers found but it is not a List")
    } catch (ex: ReflectiveOperationException) {
        Logger.warning(TAG, "Unable to find SnapshotsKt.applyObservers $ex")
    }
    return null
}

private fun findToRunTrampolined(): MutableCollection<*>? {
    try {
        val uiDispatcher = AndroidUiDispatcher::class.java
        val uiDispatcherCompanion = AndroidUiDispatcher.Companion::class.java
        val uiDispatcherCompanionField = uiDispatcher.getDeclaredField("Companion")
        val uiDispatcherCompanionObj = uiDispatcherCompanionField[null]
        val getMainMethod =
            uiDispatcherCompanion.getDeclaredMethod("getMain").apply { isAccessible = true }
        val mainObj = getMainMethod.invoke(uiDispatcherCompanionObj)
        val combinedContext = Class.forName(COMBINED_CONTEXT_FQN)
        val elementField = combinedContext.getDeclaredField("element").apply { isAccessible = true }
        val uiDispatcherObj = elementField[mainObj]

        val toRunTrampolinedField =
            uiDispatcher.getDeclaredField("toRunTrampolined").apply { isAccessible = true }
        val toRunTrampolinedObj = toRunTrampolinedField[uiDispatcherObj]
        if (toRunTrampolinedObj is MutableCollection<*>) {
            return toRunTrampolinedObj
        }
        Logger.warning(TAG, "AndroidUiDispatcher.toRunTrampolined found but it is not a MutableCollection")
    } catch (ex: ReflectiveOperationException) {
        Logger.warning(TAG, "Unable to find AndroidUiDispatcher.toRunTrampolined $ex")
    }
    return null
}

but there is a reference to a MainTestWatcher that I can’t find definition of, any one else trying this?

@KennyGoers I am using it, I just included the rule in my test class directly as:

    @get:Rule
    val paparazziCleanupRule = PaparazziCleanupRule(paparazzi = paparazzi)

(paparazzi being the Paparazzi rule)

I still can’t see what MainTestWatcher is @KatieBarnett thoughts on that? Desperately trying to get this to work 😕

Thanks for any help in this.

Working on this more, you meant to say you didn’t need the Watcher thing? I am trying that with no change, not sure why but 1.3.2 and 1.3.3 hang gradle with this fix as I’ve attempted it from the description 😕

This issue is still happening with version 1.3.3. The above hack needs to be altered to comment out

    if (viewObject !is ComposeViewAdapter) {
        return
    }

as ComposeViewAdapter is now internal.

The hack still appears to work with this commented out.

Got it working now, thanks @agrosner!