mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
fix: reclaim memory on heap pressure instead of waiting for the OS
MemoryTrimmingService.run has exactly one caller, Application.onTrimMemory,
and since API 34 the OS only delivers two levels — UI_HIDDEN(20) and
BACKGROUND(40) — both of which require the app to be backgrounded. Every bulk
reclaim we have (Tier 2 pruning, feed trimming, the hard cache trims) is gated
on BACKGROUND, so two situations get no reclaim at all:
1. Foreground use. The deprecated RUNNING_* levels are never delivered, so a
long session simply grows until the heap is full.
2. The always-on notification service. A process hosting a foreground service
can never enter the cached state, so BACKGROUND is unreachable even while
backgrounded — ActivityManager refuses it outright ("Unable to set a
background trim level on a foreground process").
Measured: a 3.4-day session sat at 492MB of a 512MB heap (3% free), paying
685ms mark-compact GCs every ~10s with dozens of threads blocked in
WaitForGcToComplete, until an input-dispatch ANR. Reproduced independently on a
second device with no foreground service at all, where the app was simply in
the foreground.
Watch our own occupancy instead. Above 70% of maxMemory, run the app's existing
BACKGROUND reclaim — deliberately the same path rather than a parallel policy,
because at that occupancy "real reclaim pressure" is simply true. A 120s floor
between runs keeps a low-yield prune from spinning.
Verified by temporarily lowering the thresholds on an emulator: the watchdog
fires and drives the real Tier 2 functions (pruneHiddenEvents,
pruneHiddenMessages, pruneOldMessages, pruneRepliesAndReactions) that had never
once executed on a foreground or always-on install.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c8a5d21ad3
commit
6420117f04
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.Context
|
||||
import android.os.BatteryManager
|
||||
import android.os.SystemClock
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
@@ -189,6 +190,7 @@ import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
@@ -335,6 +337,12 @@ class AppModules(
|
||||
}
|
||||
}
|
||||
|
||||
// Runs for the whole process lifetime (main process only — the sandbox never builds AppModules).
|
||||
// See [startHeapPressureWatchdog] for why the OS trim callbacks cannot be relied on.
|
||||
init {
|
||||
startHeapPressureWatchdog()
|
||||
}
|
||||
|
||||
// Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket
|
||||
// response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor
|
||||
// on Tor-enabled clients to transparently redirect to .onion addresses.
|
||||
@@ -1303,6 +1311,53 @@ class AppModules(
|
||||
accountsCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-triggered reclaim, because the OS-driven path cannot fire when we need it most.
|
||||
*
|
||||
* `onTrimMemory` is the ONLY caller of [trim], and since API 34 the OS delivers just two levels,
|
||||
* both of which require the app to be backgrounded:
|
||||
* - `UI_HIDDEN(20)` — activities stopped. Only trims images; never touches [LocalCache].
|
||||
* - `BACKGROUND(40)` — the process is on the system LRU list, which is what gates every bulk
|
||||
* reclaim we have (Tier 2 pruning, feed trimming, the hard cache trims).
|
||||
*
|
||||
* Two independent situations therefore get NO reclaim at all:
|
||||
* 1. **Foreground use.** The deprecated `RUNNING_*` levels are never delivered, so a long session
|
||||
* simply grows until the heap is full.
|
||||
* 2. **The always-on notification service.** A process hosting a foreground service can never enter
|
||||
* the cached state, so `BACKGROUND` is unreachable *even while backgrounded* — ActivityManager
|
||||
* refuses it outright ("Unable to set a background trim level on a foreground process").
|
||||
*
|
||||
* Measured consequence: a 3.4-day session sat at 492 MB of a 512 MB heap (3% free), paying 685 ms
|
||||
* mark-compact GCs every ~10 s with dozens of threads blocked in `WaitForGcToComplete`, until an
|
||||
* input-dispatch ANR. Reproduced independently on a second device with no foreground service at all.
|
||||
*
|
||||
* So we watch our own occupancy instead of waiting to be told. Above [HEAP_HIGH_WATER] we run the
|
||||
* app's existing `BACKGROUND` reclaim — deliberately the same path, not a parallel policy, because at
|
||||
* this occupancy "real reclaim pressure" is simply true. [MIN_RECLAIM_INTERVAL_MS] keeps a prune that
|
||||
* frees little from spinning.
|
||||
*/
|
||||
private fun startHeapPressureWatchdog() {
|
||||
applicationIOScope.launch {
|
||||
var lastRunAt = 0L
|
||||
while (isActive) {
|
||||
delay(HEAP_CHECK_INTERVAL_MS)
|
||||
val runtime = Runtime.getRuntime()
|
||||
val max = runtime.maxMemory()
|
||||
val used = runtime.totalMemory() - runtime.freeMemory()
|
||||
val ratio = used.toDouble() / max
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (ratio >= HEAP_HIGH_WATER && now - lastRunAt >= MIN_RECLAIM_INTERVAL_MS) {
|
||||
lastRunAt = now
|
||||
Log.w("AppModules") {
|
||||
"Heap at ${(ratio * 100).toInt()}% (${used / (1024 * 1024)}MB of ${max / (1024 * 1024)}MB) — " +
|
||||
"self-triggering BACKGROUND reclaim; the OS will not deliver one here."
|
||||
}
|
||||
trim(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun trim(level: Int) {
|
||||
_trimLevelEvents.tryEmit(level)
|
||||
// Backgrounding is a natural moment to flush the usage ledger too.
|
||||
@@ -1347,4 +1402,23 @@ class AppModules(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Fraction of `Runtime.maxMemory()` above which we stop waiting for an OS trim that is never
|
||||
* coming and reclaim ourselves. 70% leaves real headroom: the ANR-producing session was pinned at
|
||||
* 96% (492 MB of 512 MB, 3% free), where every allocation already stalls behind a GC.
|
||||
*/
|
||||
private const val HEAP_HIGH_WATER = 0.70
|
||||
|
||||
/** Three `Runtime` reads; cheap enough to run often, slow enough to be invisible. */
|
||||
private const val HEAP_CHECK_INTERVAL_MS = 60_000L
|
||||
|
||||
/**
|
||||
* Floor between self-triggered reclaims. Pruning cannot free events the UI still holds, so a busy
|
||||
* screen can sit above the high-water mark for a while; without this we would re-prune every
|
||||
* check and burn CPU on a heap that has nothing left to give.
|
||||
*/
|
||||
private const val MIN_RECLAIM_INTERVAL_MS = 120_000L
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user