Catching OOMs and Memory Limiter Kills in Production: Inside ProfilingManager

skydovesJaewoong Eum (skydoves)||19 min read

Catching OOMs and Memory Limiter Kills in Production: Inside ProfilingManager

Your crash dashboard shows an OutOfMemoryError climbing on a release that passed every test. The stack trace points at a byte[] allocation inside an image decoder, which is not the leak. It is just the allocation that happened to run out of room. On Android 17 you might not even get that much: as covered in the previous article on the Memory Limiter, a process can be killed for anon plus swap usage with nothing but MemoryLimiter:AnonSwap in ApplicationExitInfo. What you need is a heap dump from the user's device, taken at the moment the process died, and ProfilingManager can now give you one. The obvious question is how a heap dump survives a process that is already dying.

In this article, you'll start from the few lines that register a trigger and follow an OutOfMemoryError from your app's crash handler into system_server: how the activity manager holds a dying process, how ART dumps the heap from a forked child, why the result sometimes reaches you only on the next launch, the rate limits that decide whether you get a dump at all, and how the Memory Limiter's anomaly trigger takes a different route. The timings come from the Android 17 source and from an OOM test app run on the Android 17 emulator.

The fundamental problem: The stack trace points at the last allocation

Consider a pattern that leaks in many apps: a singleton that collects listeners and never lets them go.

object SessionEvents {
  private val listeners = CopyOnWriteArrayList<(SessionEvent) -> Unit>()

  fun register(listener: (SessionEvent) -> Unit) {
    listeners += listener
  }
}

class EditorActivity : ComponentActivity() {
  private val previewBuffer = ByteArray(30 * 1024 * 1024)

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    SessionEvents.register { event -> render(event, previewBuffer) }
  }
}

Every EditorActivity registers a lambda that captures the activity, and nothing unregisters it. Each rotation or reopen strands another 30 MB buffer. The crash eventually comes from whichever allocation runs out of room first, maybe a bitmap decode on a different screen. The stack trace tells you where the heap ran out, not what filled it.

A heap dump answers that question: it shows which objects are retained and the reference path that keeps them alive. But a heap dump has to be taken while the heap still exists, and an OutOfMemoryError on the main thread is exactly the moment the process is about to be torn down. Everything that follows is about how the platform takes a snapshot in that window.

Registering for a heap dump: Two calls and a listener

Here is the code you would write. It registers a global result listener and two triggers, one for OutOfMemoryError and one for the Memory Limiter:

@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
fun registerMemoryTriggers(context: Context, uploader: HeapDumpUploader) {
  val profilingManager = context.getSystemService(ProfilingManager::class.java) ?: return
  profilingManager.registerForAllProfilingResults(Executors.newSingleThreadExecutor()) { result ->
    val path = result.resultFilePath ?: return@registerForAllProfilingResults
    if (result.errorCode == ProfilingResult.ERROR_NONE) {
      uploader.enqueue(path, result.triggerType, result.tag)
    }
  }
  val triggers = listOf(ProfilingTrigger.TRIGGER_TYPE_OOM, ProfilingTrigger.TRIGGER_TYPE_ANOMALY)
    .mapNotNull { type -> runCatching { ProfilingTrigger.Builder(type).build() }.getOrNull() }
  profilingManager.addProfilingTriggers(triggers)
}

TRIGGER_TYPE_OOM (value 7) and TRIGGER_TYPE_ANOMALY (value 8) are new in API 37. The trigger API itself arrived in API 36, and registerForAllProfilingResults in API 35. Three details in this small block matter later:

  • Results only go to global listeners. Trigger results are never delivered to a per request callback, so register the global listener first. If a ProfilingManager in your process has already talked to the service without one, a result can be copied into your storage with no listener to receive it.
  • One trigger per type. The Builder documentation states that adding a trigger with the same type replaces the previous one. The Builder constructor also throws IllegalArgumentException("Invalid trigger type.") if the feature flag behind a type is off on the device. Those flags can be changed remotely, which is why the sample builds each trigger separately and skips any type that throws, instead of failing app startup.
  • Replacing a trigger resets its cooldown. setRateLimitingPeriodHours adds an app chosen cooldown, but every call to addProfilingTriggers stores a fresh trigger record:
public ProfilingTriggerData(
        int uid, @NonNull String packageName, int triggerType, int rateLimitingPeriodHours) {
    mUid = uid;
    mPackageName = packageName;
    mTriggerType = triggerType;
    mRateLimitingPeriodHours = rateLimitingPeriodHours;
}

The new record starts with mLastTriggeredTimeMs = 0. Triggers persist on the system side, so an app that calls addProfilingTriggers on every launch erases its own cooldown history each time. If you rely on setRateLimitingPeriodHours, register the triggers once and remember that you did.

Step 1: The crash handler does not let go

When an exception escapes a thread that has no handler of its own, the runtime calls the process's default UncaughtExceptionHandler. Android installs one called KillApplicationHandler in RuntimeInit. Looking at its core:

@Override
public void uncaughtException(Thread t, Throwable e) {
    try {
        ensureLogging(t, e);
        if (mCrashing) return;
        mCrashing = true;
        // ...
        // Bring up crash dialog, wait for it to be dismissed
        ActivityManager.getService().handleApplicationCrash(
                mApplicationObject, new ApplicationErrorReport.ParcelableCrashInfo(e));
    } catch (Throwable t2) {
        // ...
    } finally {
        // Try everything to make sure this process goes away.
        Process.killProcess(Process.myPid());
        System.exit(10);
    }
}

The key observation: handleApplicationCrash is a synchronous binder call into the activity manager, and the process kills itself only in finally, after that call returns. While the activity manager is still inside handleApplicationCrash, the crashing process stays alive with its heap intact. That is the window the profiling system uses.

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor