Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
d17206c63d fix: trim the event cache under foreground heap pressure
Android 15 (API 35) deprecated the foreground onTrimMemory levels
(TRIM_MEMORY_RUNNING_* / UI_HIDDEN). The app's only cache-trim trigger was
Application.onTrimMemory, so on Android 15+ a foreground app that keeps
scrolling its feed receives no system trim callback and the in-memory
LocalCache grows until the JVM heap hits its hard cap and the process OOMs.
That OOM surfaces as a CoroutinesInternalError thrown from whichever coroutine
happens to allocate next (e.g. BasicBundledInsert.invalidateList during a feed
refresh).

Add MemoryPressureMonitor: a lightweight foreground watchdog that polls JVM
heap usage and, when it crosses a high watermark, runs the existing
MemoryTrimmingService. This restores a foreground trim trigger driven by actual
heap usage rather than the now-deprecated system callback. The trim path itself
is unchanged and non-destructive (weak-ref cleanup + pruning of old/hidden/
expired events).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WSqQzFNdD7twNPES75ojiH
2026-06-19 21:09:30 +00:00
3 changed files with 201 additions and 0 deletions

View File

@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
import com.vitorpamplona.amethyst.service.eventCache.MemoryPressureMonitor
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
@@ -651,6 +652,20 @@ class AppModules(
MemoryTrimmingService(cache)
}
// Foreground heap watchdog. Android 15+ (API 35) deprecated the foreground onTrimMemory levels
// (TRIM_MEMORY_RUNNING_* / UI_HIDDEN), so a foreground app scrolling its feed no longer receives
// a system trim callback and the LocalCache can grow until the process OOMs. This restores a
// foreground trim trigger driven by actual heap usage, reusing the same trimmingService.
val memoryPressureMonitor by
lazy {
MemoryPressureMonitor(
onPressure = {
val loggedIn = accountsCache.accounts.value.values
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts())
},
)
}
// as new accounts are loaded, updates the state of the TorRelaySettings, which produces new TorRelayEvaluator
// and reconnects relays if the configuration has been changed.
val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationIOScope)
@@ -738,6 +753,12 @@ class AppModules(
}
}
// Trim the event cache before the heap fills up. Needed because Android 15+ no longer
// delivers foreground onTrimMemory callbacks (see MemoryPressureMonitor).
applicationIOScope.launch {
memoryPressureMonitor.run()
}
applicationIOScope.launch {
// loads main account quickly.
LocalPreferences.loadAccountConfigFromEncryptedStorage()

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.eventCache
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
/**
* Watches the JVM heap and proactively triggers a cache trim before the process runs out of memory.
*
* The app already trims [LocalCache][com.vitorpamplona.amethyst.model.LocalCache] from
* `Application.onTrimMemory`, but Android 15 (API 35) deprecated the foreground trim levels
* (`TRIM_MEMORY_RUNNING_*`, `TRIM_MEMORY_UI_HIDDEN`). A foreground app that keeps scrolling its feed
* therefore receives no system trim callback, and the in-memory event store grows until the heap hits
* its hard cap and the process OOMs (often surfacing as a `CoroutinesInternalError` thrown from
* whichever coroutine happens to allocate next). This monitor restores a foreground trigger that is
* independent of the system callback.
*/
class MemoryPressureMonitor(
private val checkIntervalMs: Long = DEFAULT_CHECK_INTERVAL_MS,
private val highWatermark: Double = DEFAULT_HIGH_WATERMARK,
private val usedHeapBytes: () -> Long = { Runtime.getRuntime().let { it.totalMemory() - it.freeMemory() } },
private val maxHeapBytes: () -> Long = { Runtime.getRuntime().maxMemory() },
private val onPressure: suspend () -> Unit,
) {
suspend fun run() {
while (currentCoroutineContext().isActive) {
delay(checkIntervalMs)
val used = usedHeapBytes()
val max = maxHeapBytes()
if (isUnderPressure(used, max, highWatermark)) {
Log.w("MemoryPressureMonitor") {
"Heap at ${used / (1024 * 1024)}/${max / (1024 * 1024)} MB " +
"(>= ${(highWatermark * 100).toInt()}%). Trimming the event cache."
}
onPressure()
}
}
}
companion object {
// Checked frequently enough to react before a fast scroll fills the heap, but cheap: the
// probe is a couple of Runtime reads and a trim only fires when actually over the watermark.
const val DEFAULT_CHECK_INTERVAL_MS = 30_000L
// Leaves headroom below the hard cap so the trim (and the GC it enables) has room to work
// before any single allocation is forced to fail.
const val DEFAULT_HIGH_WATERMARK = 0.85
fun isUnderPressure(
usedBytes: Long,
maxBytes: Long,
watermark: Double,
): Boolean {
if (maxBytes <= 0L) return false
return usedBytes.toDouble() / maxBytes.toDouble() >= watermark
}
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.eventCache
import com.vitorpamplona.amethyst.service.eventCache.MemoryPressureMonitor.Companion.isUnderPressure
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MemoryPressureMonitorTest {
private val mb = 1024L * 1024L
@Test
fun belowWatermarkIsNotUnderPressure() {
assertFalse(isUnderPressure(usedBytes = 400 * mb, maxBytes = 512 * mb, watermark = 0.85))
}
@Test
fun atWatermarkIsUnderPressure() {
// 0.85 * 512 = 435.2 MB
assertTrue(isUnderPressure(usedBytes = 436 * mb, maxBytes = 512 * mb, watermark = 0.85))
}
@Test
fun wellAboveWatermarkIsUnderPressure() {
assertTrue(isUnderPressure(usedBytes = 510 * mb, maxBytes = 512 * mb, watermark = 0.85))
}
@Test
fun unknownMaxHeapNeverReportsPressure() {
// Runtime.maxMemory() returns Long.MAX_VALUE / 0 / -1 on platforms that can't report a cap.
assertFalse(isUnderPressure(usedBytes = 1_000 * mb, maxBytes = 0L, watermark = 0.85))
assertFalse(isUnderPressure(usedBytes = 1_000 * mb, maxBytes = -1L, watermark = 0.85))
}
@Test
fun firesOnPressureOncePerProbeWhenHeapIsHigh() =
runTest {
var trims = 0
val monitor =
MemoryPressureMonitor(
checkIntervalMs = 10L,
highWatermark = 0.85,
usedHeapBytes = { 500L * mb },
maxHeapBytes = { 512L * mb },
onPressure = { trims++ },
)
// run() loops forever; advance exactly one interval then stop it.
val job = launch { monitor.run() }
advanceTimeBy(11L)
job.cancel()
assertEquals(1, trims)
}
@Test
fun doesNotTrimWhenHeapIsBelowWatermark() =
runTest {
var trims = 0
val monitor =
MemoryPressureMonitor(
checkIntervalMs = 10L,
highWatermark = 0.85,
usedHeapBytes = { 100L * mb },
maxHeapBytes = { 512L * mb },
onPressure = { trims++ },
)
val job = launch { monitor.run() }
advanceTimeBy(11L)
job.cancel()
assertEquals(0, trims)
}
}