From ef89352d09bb94b0b77a467d8997eaf17cb13ee1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 16 Jul 2026 10:32:33 +0700 Subject: [PATCH] perf(memory): drop disposable caches on pressure --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 6 +++ go_backend/cover.go | 7 +++ go_backend/exports.go | 18 ++++++++ go_backend/extension_health.go | 6 +++ go_backend/extension_runtime.go | 6 +++ go_backend/memory_release_test.go | 44 +++++++++++++++++++ ios/Runner/AppDelegate.swift | 8 ++++ lib/main.dart | 2 +- lib/services/platform_bridge.dart | 6 ++- 9 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 go_backend/memory_release_test.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index ec5b5d10..030fe823 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -3147,6 +3147,12 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } + "releaseMemoryUnderPressure" -> { + withContext(Dispatchers.IO) { + Gobackend.releaseMemoryUnderPressure() + } + result.success(null) + } "setMetadataLanguage" -> { val tag = call.argument("tag") ?: "" withContext(Dispatchers.IO) { diff --git a/go_backend/cover.go b/go_backend/cover.go index 0c8c3830..16258c46 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -87,6 +87,13 @@ var ( coverFetch = fetchCoverBytes ) +func clearCoverMemoryCache() { + coverMu.Lock() + coverCache = map[string]*coverCacheEntry{} + coverCacheBytes = 0 + coverMu.Unlock() +} + // fetchCoverCached returns cover bytes for a final URL, collapsing concurrent // requests for the same URL into a single fetch (singleflight) and caching // results in memory for the duration of an album batch. The returned slice is diff --git a/go_backend/exports.go b/go_backend/exports.go index eee7b700..3cc6e767 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -36,7 +36,25 @@ func metadataAcceptLanguage() string { // when backgrounded, so the Go side's RSS doesn't sit at its high-water mark // after large downloads/tag writes. func ReleaseMemory() { + releaseMemory(false) +} + +// ReleaseMemoryUnderPressure additionally drops disposable live caches. It is +// reserved for an OS memory-pressure signal; ordinary backgrounding keeps +// network-backed caches warm. +func ReleaseMemoryUnderPressure() { + releaseMemory(true) +} + +func releaseMemory(underPressure bool) { drainAllIsolatedRuntimePools() + CloseIdleConnections() + if underPressure { + clearCoverMemoryCache() + globalLyricsCache.ClearAll() + clearPrivateIPCache() + clearExtensionHealthCache() + } debug.FreeOSMemory() } diff --git a/go_backend/extension_health.go b/go_backend/extension_health.go index 414ddaa8..5543fce5 100644 --- a/go_backend/extension_health.go +++ b/go_backend/extension_health.go @@ -52,6 +52,12 @@ var ( extensionHealthCache = map[string]cachedExtensionHealthResult{} ) +func clearExtensionHealthCache() { + extensionHealthCacheMu.Lock() + extensionHealthCache = map[string]cachedExtensionHealthResult{} + extensionHealthCacheMu.Unlock() +} + func CheckExtensionHealthJSON(extensionID string) (string, error) { manager := getExtensionManager() ext, err := manager.GetExtension(extensionID) diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 52d6f78b..4fd0b91e 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -181,6 +181,12 @@ var ( privateIPCacheMu sync.RWMutex ) +func clearPrivateIPCache() { + privateIPCacheMu.Lock() + privateIPCache = make(map[string]privateIPCacheEntry) + privateIPCacheMu.Unlock() +} + func newExtensionRuntime(ext *loadedExtension) *extensionRuntime { jar, _ := newSimpleCookieJar() diff --git a/go_backend/memory_release_test.go b/go_backend/memory_release_test.go new file mode 100644 index 00000000..256853ac --- /dev/null +++ b/go_backend/memory_release_test.go @@ -0,0 +1,44 @@ +package gobackend + +import ( + "testing" + "time" +) + +func TestReleaseMemoryUnderPressureClearsDisposableCaches(t *testing.T) { + clearCoverMemoryCache() + coverCachePut("https://example.com/cover.jpg", []byte("cover")) + globalLyricsCache.ClearAll() + globalLyricsCache.Set("artist", "track", 120, &LyricsResponse{PlainLyrics: "lyrics"}) + + privateIPCacheMu.Lock() + privateIPCache["example.com"] = privateIPCacheEntry{expiresAt: time.Now().Add(time.Hour)} + privateIPCacheMu.Unlock() + extensionHealthCacheMu.Lock() + extensionHealthCache["extension"] = cachedExtensionHealthResult{expiresAt: time.Now().Add(time.Hour)} + extensionHealthCacheMu.Unlock() + + ReleaseMemoryUnderPressure() + + coverMu.Lock() + coverEntries, coverBytes := len(coverCache), coverCacheBytes + coverMu.Unlock() + if coverEntries != 0 || coverBytes != 0 { + t.Fatalf("cover cache retained %d entries/%d bytes", coverEntries, coverBytes) + } + if globalLyricsCache.Size() != 0 { + t.Fatalf("lyrics cache retained %d entries", globalLyricsCache.Size()) + } + privateIPCacheMu.RLock() + privateEntries := len(privateIPCache) + privateIPCacheMu.RUnlock() + if privateEntries != 0 { + t.Fatalf("private IP cache retained %d entries", privateEntries) + } + extensionHealthCacheMu.Lock() + healthEntries := len(extensionHealthCache) + extensionHealthCacheMu.Unlock() + if healthEntries != 0 { + t.Fatalf("health cache retained %d entries", healthEntries) + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index d56d22b9..4c8c8cf9 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -733,6 +733,14 @@ import Gobackend case "clearLogs": GobackendClearLogs() return nil + + case "releaseMemory": + GobackendReleaseMemory() + return nil + + case "releaseMemoryUnderPressure": + GobackendReleaseMemoryUnderPressure() + return nil case "getLogCount": let response = GobackendGetLogCount() diff --git a/lib/main.dart b/lib/main.dart index becb1564..49c54764 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -229,7 +229,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> if (CoverCacheManager.isInitialized) { CoverCacheManager.instance.store.emptyMemoryCache(); } - unawaited(PlatformBridge.releaseNativeMemory()); + unawaited(PlatformBridge.releaseNativeMemory(underPressure: true)); } void _initializeDeferredProviders() { diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 3ecb986e..dd83a338 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1168,9 +1168,11 @@ class PlatformBridge { /// Ask the Go backend to GC and return freed heap to the OS. Best-effort: /// safe to call on memory pressure or when the app is backgrounded. - static Future releaseNativeMemory() async { + static Future releaseNativeMemory({bool underPressure = false}) async { try { - await _channel.invokeMethod('releaseMemory'); + await _channel.invokeMethod( + underPressure ? 'releaseMemoryUnderPressure' : 'releaseMemory', + ); } catch (_) {} }