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 030fe823..2e2c46b1 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -3153,6 +3153,12 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } + "getGoRuntimeMetrics" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getRuntimeMetricsJSON() + } + result.success(response) + } "setMetadataLanguage" -> { val tag = call.argument("tag") ?: "" withContext(Dispatchers.IO) { diff --git a/go_backend/library_scan_single_pass_test.go b/go_backend/library_scan_single_pass_test.go index 364e5856..e41d8e83 100644 --- a/go_backend/library_scan_single_pass_test.go +++ b/go_backend/library_scan_single_pass_test.go @@ -11,7 +11,7 @@ import ( flac "github.com/go-flac/go-flac/v2" ) -func writeSinglePassTestFlac(t *testing.T, path string, cover []byte) { +func writeSinglePassTestFlac(t testing.TB, path string, cover []byte) { t.Helper() streamInfo := make([]byte, 34) const sampleRate = 44100 diff --git a/go_backend/performance_benchmark_test.go b/go_backend/performance_benchmark_test.go new file mode 100644 index 00000000..e4b852e9 --- /dev/null +++ b/go_backend/performance_benchmark_test.go @@ -0,0 +1,106 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/dop251/goja" +) + +var ( + benchmarkStringSink string + benchmarkIntSink int64 + benchmarkScanSink *LibraryScanResult +) + +func BenchmarkGojaProviderInvocation(b *testing.B) { + vm := goja.New() + if _, err := vm.RunString(`var extension = { searchTracks: function(query, limit) { return query.length + limit; } };`); err != nil { + b.Fatal(err) + } + + b.Run("direct_callable", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + value, err := invokeExtensionMethod(vm, "searchTracks", "lossless", 25) + if err != nil { + b.Fatal(err) + } + benchmarkIntSink = value.ToInteger() + } + }) + + b.Run("compile_source", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + script := fmt.Sprintf(`extension.searchTracks(%q, %d)`, "lossless", 25) + value, err := vm.RunString(script) + if err != nil { + b.Fatal(err) + } + benchmarkIntSink = value.ToInteger() + } + }) +} + +func BenchmarkCheckFilesExistBatch(b *testing.B) { + const trackCount = 1000 + dir := b.TempDir() + idx := &ISRCIndex{ + index: make(map[string]string, trackCount), + files: make(map[string]isrcFileEntry), + outputDir: dir, + } + tracks := make([]map[string]string, trackCount) + for i := 0; i < trackCount; i++ { + isrc := fmt.Sprintf("USAA%08d", i) + idx.index[isrc] = filepath.Join(dir, fmt.Sprintf("%d.flac", i)) + tracks[i] = map[string]string{ + "isrc": isrc, + "track_name": fmt.Sprintf("Track %d", i), + "artist_name": "Artist", + } + } + idx.buildTime.Store(time.Now().UnixNano()) + isrcIndexCacheMu.Lock() + isrcIndexCache[dir] = idx + isrcIndexCacheMu.Unlock() + b.Cleanup(func() { InvalidateISRCCache(dir) }) + payload, err := json.Marshal(tracks) + if err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.SetBytes(int64(len(payload))) + b.ResetTimer() + for b.Loop() { + result, err := CheckFilesExistParallel(dir, string(payload)) + if err != nil { + b.Fatal(err) + } + benchmarkStringSink = result + } +} + +func BenchmarkLibraryScanFLACSinglePass(b *testing.B) { + dir := b.TempDir() + path := filepath.Join(dir, "track.flac") + cover := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3} + writeSinglePassTestFlac(b, path, cover) + cacheDir := filepath.Join(dir, "covers") + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + result := &LibraryScanResult{FilePath: path, Format: "flac"} + var err error + benchmarkScanSink, err = scanFLACFileWithCoverCache(path, result, "", cacheDir, "stable-key") + if err != nil { + b.Fatal(err) + } + } +} diff --git a/go_backend/runtime_metrics.go b/go_backend/runtime_metrics.go new file mode 100644 index 00000000..d81248f4 --- /dev/null +++ b/go_backend/runtime_metrics.go @@ -0,0 +1,54 @@ +package gobackend + +import ( + "encoding/json" + "runtime" + "time" +) + +type goRuntimeMetrics struct { + CapturedAtUnixMS int64 `json:"captured_at_unix_ms"` + HeapAllocBytes uint64 `json:"heap_alloc_bytes"` + HeapInuseBytes uint64 `json:"heap_inuse_bytes"` + HeapIdleBytes uint64 `json:"heap_idle_bytes"` + HeapReleasedBytes uint64 `json:"heap_released_bytes"` + HeapObjects uint64 `json:"heap_objects"` + StackInuseBytes uint64 `json:"stack_inuse_bytes"` + SysBytes uint64 `json:"sys_bytes"` + NextGCBytes uint64 `json:"next_gc_bytes"` + LastGCUnixMS int64 `json:"last_gc_unix_ms"` + NumGC uint32 `json:"num_gc"` + PauseTotalNS uint64 `json:"pause_total_ns"` + Goroutines int `json:"goroutines"` + GOMAXPROCS int `json:"gomaxprocs"` + CgoCalls int64 `json:"cgo_calls"` +} + +// GetRuntimeMetricsJSON reports Go-owned runtime memory. Native allocations +// from Flutter, FFmpeg, SQLite, and the OS are intentionally outside it. +func GetRuntimeMetricsJSON() string { + var stats runtime.MemStats + runtime.ReadMemStats(&stats) + metrics := goRuntimeMetrics{ + CapturedAtUnixMS: time.Now().UnixMilli(), + HeapAllocBytes: stats.HeapAlloc, + HeapInuseBytes: stats.HeapInuse, + HeapIdleBytes: stats.HeapIdle, + HeapReleasedBytes: stats.HeapReleased, + HeapObjects: stats.HeapObjects, + StackInuseBytes: stats.StackInuse, + SysBytes: stats.Sys, + NextGCBytes: stats.NextGC, + LastGCUnixMS: int64(stats.LastGC / uint64(time.Millisecond)), + NumGC: stats.NumGC, + PauseTotalNS: stats.PauseTotalNs, + Goroutines: runtime.NumGoroutine(), + GOMAXPROCS: runtime.GOMAXPROCS(0), + CgoCalls: runtime.NumCgoCall(), + } + payload, err := json.Marshal(metrics) + if err != nil { + return "{}" + } + return string(payload) +} diff --git a/go_backend/runtime_metrics_test.go b/go_backend/runtime_metrics_test.go new file mode 100644 index 00000000..10a9c436 --- /dev/null +++ b/go_backend/runtime_metrics_test.go @@ -0,0 +1,19 @@ +package gobackend + +import ( + "encoding/json" + "testing" +) + +func TestGetRuntimeMetricsJSON(t *testing.T) { + var metrics goRuntimeMetrics + if err := json.Unmarshal([]byte(GetRuntimeMetricsJSON()), &metrics); err != nil { + t.Fatalf("runtime metrics JSON: %v", err) + } + if metrics.CapturedAtUnixMS <= 0 || metrics.HeapInuseBytes == 0 || metrics.SysBytes == 0 || metrics.Goroutines <= 0 || metrics.GOMAXPROCS <= 0 { + t.Fatalf("runtime metrics = %#v", metrics) + } + if metrics.HeapAllocBytes > metrics.HeapInuseBytes { + t.Fatalf("heap alloc %d exceeds heap in-use %d", metrics.HeapAllocBytes, metrics.HeapInuseBytes) + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 4c8c8cf9..241c633c 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -741,6 +741,9 @@ import Gobackend case "releaseMemoryUnderPressure": GobackendReleaseMemoryUnderPressure() return nil + + case "getGoRuntimeMetrics": + return GobackendGetRuntimeMetricsJSON() case "getLogCount": let response = GobackendGetLogCount() diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index dd83a338..2ebc1ab1 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1176,6 +1176,11 @@ class PlatformBridge { } catch (_) {} } + static Future> getGoRuntimeMetrics() async { + final result = await _channel.invokeMethod('getGoRuntimeMetrics'); + return _decodeRequiredMapResult(result, 'getGoRuntimeMetrics'); + } + /// Tells the backend the app's display language so metadata providers /// localize by it instead of IP geolocation. Best-effort. static Future setMetadataLanguage(String tag) async {