From e3f7817b2964655a7e453add8a613f7d5c4c8bad Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:28:47 +0700 Subject: [PATCH] fix(memory): release retired runtimes and idle download heap --- .../com/zarz/spotiflac/DownloadService.kt | 12 ++++++++ go_backend/extension_manager_runtime.go | 3 ++ go_backend/memory_release_test.go | 29 +++++++++++++++++++ lib/providers/download_queue_provider.dart | 4 +++ 4 files changed, 48 insertions(+) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index b4ba074e..778d5108 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -1395,6 +1395,7 @@ class DownloadService : Service() { failed = counts.failed, ) currentStatus = "finalizing" + releaseIdleDownloadMemory() writeNativeWorkerSnapshot( isRunning = false, isPaused = false, @@ -1702,6 +1703,7 @@ class DownloadService : Service() { failed = counts.failed, ) currentStatus = "finalizing" + releaseIdleDownloadMemory() writeNativeWorkerSnapshot( isRunning = false, isPaused = false, @@ -1718,6 +1720,16 @@ class DownloadService : Service() { } } + private fun releaseIdleDownloadMemory() { + try { + // All workers and album tagging have finished. Return unused Go + // heap without forcing a collection between individual tracks. + Gobackend.releaseMemory() + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to release idle download memory: ${e.message}") + } + } + @Synchronized private fun ensureWakeLock() { val existingWakeLock = wakeLock diff --git a/go_backend/extension_manager_runtime.go b/go_backend/extension_manager_runtime.go index 3b321cf6..7cfb71f9 100644 --- a/go_backend/extension_manager_runtime.go +++ b/go_backend/extension_manager_runtime.go @@ -201,6 +201,9 @@ func acquireIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *exte ext.isolatedPoolMu.Lock() if n := len(ext.isolatedPool); n > 0 { handle := ext.isolatedPool[n-1] + // Reslicing alone retains this handle in the backing array. If the + // borrowed VM is retired after an error, its heap would stay alive. + ext.isolatedPool[n-1] = nil ext.isolatedPool = ext.isolatedPool[:n-1] ext.isolatedPoolMu.Unlock() return handle.vm, handle.runtime, nil diff --git a/go_backend/memory_release_test.go b/go_backend/memory_release_test.go index 256853ac..32132d2c 100644 --- a/go_backend/memory_release_test.go +++ b/go_backend/memory_release_test.go @@ -1,10 +1,39 @@ package gobackend import ( + "runtime" "testing" "time" + "weak" ) +func TestDiscardedPooledRuntimeCanBeCollected(t *testing.T) { + ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider) + discarded := func() weak.Pointer[extensionRuntime] { + vm, rt, err := acquireIsolatedExtensionRuntime(ext) + if err != nil { + t.Fatal(err) + } + if err := vm.Set("temporaryDownloadBytes", vm.NewArrayBuffer(make([]byte, 16<<20))); err != nil { + t.Fatal(err) + } + releaseIsolatedExtensionRuntime(ext, vm, rt, true, true, nil) + vm, rt, err = acquireIsolatedExtensionRuntime(ext) + if err != nil { + t.Fatal(err) + } + pointer := weak.Make(rt) + // An operation error retires the borrowed VM instead of pooling it. + releaseIsolatedExtensionRuntime(ext, vm, rt, false, true, nil) + return pointer + }() + runtime.GC() + if discarded.Value() != nil { + t.Fatal("retired runtime and its download buffer remain reachable from the idle pool") + } + runtime.KeepAlive(ext) +} + func TestReleaseMemoryUnderPressureClearsDisposableCaches(t *testing.T) { clearCoverMemoryCache() coverCachePut("https://example.com/cover.jpg", []byte("cover")) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 3f16cf9b..8e80250a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1893,6 +1893,10 @@ class DownloadQueueNotifier extends Notifier { _downloadCount = 0; } + if (!stoppedWhilePaused) { + await PlatformBridge.releaseNativeMemory(); + } + _log.i( 'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart', );