From aaf27a7f9ec72e208ebccead3440bad633391d15 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:10:36 +0200 Subject: [PATCH] fix(android): extend the RSS eviction watchdog beyond desktop and log tier signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android trim callbacks are best-effort — LMK can kill without ever delivering one — yet the RSS watchdog only ran on desktop with a fixed 1.5GB bar no 2GB TV box ever reaches before dying. The watchdog now runs on Android with a threshold scaled to device RAM, an eviction floor and cooldown against refetch churn, and a lower backgrounded bar since a paused app is LMK's first candidate. The logs-screen header and startup line now record the renderer, the effects tier with its raw hardware signals, and the boot RSS, so uploaded reports answer whether the reduced tier engaged. Ref #1349 --- lib/main.dart | 74 +++++++++++++++++++++++---- lib/screens/settings/logs_screen.dart | 12 +++++ lib/services/device_performance.dart | 17 ++++-- 3 files changed, 88 insertions(+), 15 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index b6204c5f..7b87ab14 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -224,6 +224,11 @@ Future _bootstrapApp() async { 'Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer' ' [effects: ${DevicePerformance.describeSync()}]', ); + if (Platform.isAndroid) { + // Baseline for the RSS watchdog thresholds and a sanity anchor against + // `adb shell dumpsys meminfo` when tuning them. + appLogger.i('Startup RSS: ${ProcessInfo.currentRss >> 20}MB'); + } await DownloadStorageService.instance.initialize(settings); @@ -461,24 +466,20 @@ class _MainAppState extends State with WidgetsBindingObserver { /// Last time server health probes ran from a resume event (cooldown for desktop) DateTime _lastResumeProbe = DateTime(0); - /// Periodic memory check timer for desktop platforms + /// Periodic RSS watchdog timer (desktop + Android). Timer? _memoryCheckTimer; + /// Last watchdog eviction, for the cooldown; RSS at that moment so a + /// still-climbing RSS can re-evict inside the cooldown window. + DateTime _lastRssEviction = DateTime(0); + int _lastEvictionRss = 0; + @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - if (PlatformDetector.isDesktopOS()) { - _memoryCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) { - final rss = ProcessInfo.currentRss; - if (rss > 1536 * 1024 * 1024) { - // 1.5GB - appLogger.w('RSS high ($rss bytes), evicting image caches'); - _evictImageCaches(); - } - }); - } + _startRssWatchdog(); _serverManager = MultiServerManager(); _aggregationService = DataAggregationService(_serverManager); @@ -556,6 +557,48 @@ class _MainAppState extends State with WidgetsBindingObserver { _evictImageCaches(); } + /// RSS-based image-cache eviction. Desktop keeps its fixed 1.5GB bar; + /// Android scales to the device because LMK on a 2GB TV box kills well + /// below any fixed desktop threshold — and Android trim callbacks + /// ([didHaveMemoryPressure]) are best-effort, LMK can kill without ever + /// delivering one (#1349). + void _startRssWatchdog() { + final int threshold; + final Duration period; + if (PlatformDetector.isDesktopOS()) { + threshold = 1536 << 20; // 1.5GB + period = const Duration(seconds: 30); + } else if (Platform.isAndroid) { + final totalMem = DevicePerformance.totalMemBytes; + threshold = totalMem != null ? (totalMem * 0.45).round().clamp(512 << 20, 1536 << 20) : 1 << 30; + // Decode bursts can spike RSS in seconds on low-end boxes; the read + // itself is an in-process syscall, cheap enough for a short period. + period = DevicePerformance.isLowEndHardware ? const Duration(seconds: 15) : const Duration(seconds: 30); + } else { + return; // iOS/tvOS: jetsam pressure arrives via didHaveMemoryPressure. + } + + _memoryCheckTimer = Timer.periodic(period, (_) { + final rss = ProcessInfo.currentRss; + if (rss <= threshold) return; + final cache = PaintingBinding.instance.imageCache; + // Floor + cooldown: clearing an already-small cache buys nothing, and + // refetch churn is its own memory-spike and jank source. Inside the + // cooldown, re-evict only if RSS kept climbing past the last eviction. + if (cache.currentSizeBytes < (8 << 20)) return; + final now = DateTime.now(); + final inCooldown = now.difference(_lastRssEviction) < const Duration(seconds: 60); + if (inCooldown && rss <= _lastEvictionRss) return; + _lastRssEviction = now; + _lastEvictionRss = rss; + appLogger.w( + 'RSS high (${rss >> 20}MB > ${threshold >> 20}MB), evicting image caches ' + '(cache ${cache.currentSizeBytes >> 20}MB/${cache.currentSize} images, ${cache.liveImageCount} live)', + ); + _evictImageCaches(); + }); + } + void _evictImageCaches() { PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clearLiveImages(); @@ -671,6 +714,15 @@ class _MainAppState extends State with WidgetsBindingObserver { // 1GB _evictImageCaches(); } + } else if (Platform.isAndroid) { + // A backgrounded app is LMK's first candidate; shed the image + // caches at a lower bar than the foreground watchdog to survive + // the HOME press on low-RAM boxes. + final totalMem = DevicePerformance.totalMemBytes; + final bar = totalMem != null ? (totalMem * 0.35).round() : 768 << 20; + if (ProcessInfo.currentRss > bar) { + _evictImageCaches(); + } } case AppLifecycleState.inactive: case AppLifecycleState.hidden: diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index e85a49af..43c4d8f1 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -16,6 +16,7 @@ import '../../i18n/strings.g.dart'; import '../../mixins/mounted_set_state_mixin.dart'; import '../../utils/dialogs.dart'; import '../../main.dart' show gitCommit; +import '../../services/device_performance.dart'; import '../../utils/app_logger.dart'; import '../../utils/formatters.dart'; import '../../utils/platform_detector.dart'; @@ -58,6 +59,15 @@ class _LogsScreenState extends State with MountedSetStateMixin { final suffix = reasons.isEmpty ? '' : ' (${reasons.join(', ')})'; buffer.writeln('TV mode: yes$suffix'); } + // Renderer + effects tier turn "did the reduced tier engage on this + // device" into something answerable from any uploaded log (#1349). + String renderer; + try { + renderer = await const MethodChannel('com.plezy/theme').invokeMethod('getRenderer') ?? 'unknown'; + } catch (_) { + renderer = 'unknown'; + } + buffer.writeln('Renderer: $renderer'); } else if (Platform.isIOS) { final info = await deviceInfo.iosInfo; buffer.writeln('iOS ${info.systemVersion}'); @@ -71,6 +81,8 @@ class _LogsScreenState extends State with MountedSetStateMixin { buffer.writeln('Linux ${info.versionId ?? info.id}'); } + buffer.writeln('Effects: ${DevicePerformance.describeSync()}'); + setStateIfMounted(() => _deviceInfo = buffer.toString().trimRight()); } diff --git a/lib/services/device_performance.dart b/lib/services/device_performance.dart index f30a8ec5..e0233411 100644 --- a/lib/services/device_performance.dart +++ b/lib/services/device_performance.dart @@ -64,6 +64,10 @@ class DevicePerformance { } } + /// Total device RAM as reported by the platform, or null off-Android / + /// before init. Used to scale memory-watchdog thresholds to the device. + static int? get totalMemBytes => _instance?._totalMemBytes; + /// Auto-detected low-end hardware (32-bit process / low-RAM / ≤2.2 GiB), /// independent of the visual-effects override. Use this for decisions tied to /// the hardware itself — e.g. the codec→display video pipeline on cheap TV @@ -113,18 +117,23 @@ class DevicePerformance { } } - /// One-line tier summary for the startup log, e.g. - /// `reduced (auto: 32-bit, lowRam, 1.9GiB)` or `full (forced)`. + /// One-line tier summary for the startup log and bug-report headers, e.g. + /// `reduced (auto: 32-bit, lowRam, 1.9GiB)` or `full (forced; hw: 64-bit, 2.8GiB)`. + /// + /// Raw signals are always included (even when the tier is forced) so an + /// uploaded log answers "did the reduced tier engage, and why / why not". static String describeSync() { final instance = _instance; if (instance == null) return 'unknown'; final tier = isReduced ? 'reduced' : 'full'; - if (instance._override != VisualEffectsSetting.auto) return '$tier (forced)'; final signals = [ if (instance._is64Bit != null) (instance._is64Bit! ? '64-bit' : '32-bit'), - if (instance._isLowRam == true) 'lowRam', + if (instance._isLowRam != null) 'lowRam:${instance._isLowRam}', if (instance._totalMemBytes != null) '${(instance._totalMemBytes! / (1024 * 1024 * 1024)).toStringAsFixed(1)}GiB', ]; + if (instance._override != VisualEffectsSetting.auto) { + return signals.isEmpty ? '$tier (forced)' : '$tier (forced; hw: ${signals.join(', ')})'; + } return signals.isEmpty ? tier : '$tier (auto: ${signals.join(', ')})'; }