Skip to content

Commit 4601c40

Browse files
committed
perf: debounced extension storage writes, lifecycle cleanup, and smart queue optimizations
- Replace synchronous storage.json writes with 400ms debounced flush to reduce I/O during rapid storageSet/storageRemove calls - Flush and close storage flusher on extension unload; call cleanupExtensions on Activity destroy and AppLifecycleListener detach - Smart Queue: query secondary source only when primary returns insufficient results instead of always running both in parallel - Infer seekSupported early for queue items (disable for YouTube and live-decrypted streams) - Add relaxed fallback artist-repeat limit when strict selection yields no candidates - Add extension_runtime_storage_test.go
1 parent 51d1148 commit 4601c40

File tree

7 files changed

+355
-63
lines changed

7 files changed

+355
-63
lines changed

android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1347,6 +1347,11 @@ class MainActivity: AudioServiceFragmentActivity() {
13471347
}
13481348

13491349
override fun onDestroy() {
1350+
try {
1351+
Gobackend.cleanupExtensions()
1352+
} catch (e: Exception) {
1353+
android.util.Log.w("SpotiFLAC", "Failed to cleanup extensions on destroy: ${e.message}")
1354+
}
13501355
stopDownloadProgressStream()
13511356
stopLibraryScanProgressStream()
13521357
super.onDestroy()

go_backend/extension_manager.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@ type LoadedExtension struct {
4848
Manifest *ExtensionManifest `json:"manifest"`
4949
VM *goja.Runtime `json:"-"`
5050
VMMu sync.Mutex `json:"-"`
51-
Enabled bool `json:"enabled"`
52-
Error string `json:"error,omitempty"`
53-
DataDir string `json:"data_dir"`
54-
SourceDir string `json:"source_dir"`
55-
IconPath string `json:"icon_path"`
51+
runtime *ExtensionRuntime
52+
Enabled bool `json:"enabled"`
53+
Error string `json:"error,omitempty"`
54+
DataDir string `json:"data_dir"`
55+
SourceDir string `json:"source_dir"`
56+
IconPath string `json:"icon_path"`
5657
}
5758

5859
type ExtensionManager struct {
@@ -243,6 +244,7 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
243244
}
244245

245246
runtime := NewExtensionRuntime(ext)
247+
ext.runtime = runtime
246248
runtime.RegisterAPIs(vm)
247249
runtime.RegisterGoBackendAPIs(vm)
248250

@@ -295,6 +297,13 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
295297
GoLog("[Extension] Cleanup called for %s\n", extensionID)
296298
}
297299
}
300+
if ext.runtime != nil {
301+
if err := ext.runtime.flushStorageNow(); err != nil {
302+
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
303+
}
304+
ext.runtime.closeStorageFlusher()
305+
ext.runtime = nil
306+
}
298307

299308
delete(m.extensions, extensionID)
300309
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
@@ -536,7 +545,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
536545
extDir := existing.SourceDir
537546
wasEnabled := existing.Enabled
538547

539-
m.CleanupExtension(existing.ID)
540548
m.UnloadExtension(existing.ID)
541549

542550
if extDir != "" {
@@ -909,7 +917,6 @@ func (m *ExtensionManager) UnloadAllExtensions() {
909917
m.mu.Unlock()
910918

911919
for _, id := range extensionIDs {
912-
m.CleanupExtension(id)
913920
m.UnloadExtension(id)
914921
}
915922

go_backend/extension_runtime.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,18 @@ type ExtensionRuntime struct {
8989
dataDir string
9090
vm *goja.Runtime
9191

92-
storageMu sync.RWMutex
93-
storageCache map[string]interface{}
94-
storageLoaded bool
92+
storageMu sync.RWMutex
93+
storageCache map[string]interface{}
94+
storageLoaded bool
95+
storageDirty bool
96+
storageClosed bool
97+
storageTimer *time.Timer
98+
storageWriteMu sync.Mutex
9599

96100
credentialsMu sync.RWMutex
97101
credentialsCache map[string]interface{}
98102
credentialsLoaded bool
103+
storageFlushDelay time.Duration
99104
}
100105

101106
type privateIPCacheEntry struct {
@@ -118,12 +123,13 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
118123
jar, _ := newSimpleCookieJar()
119124

120125
runtime := &ExtensionRuntime{
121-
extensionID: ext.ID,
122-
manifest: ext.Manifest,
123-
settings: make(map[string]interface{}),
124-
cookieJar: jar,
125-
dataDir: ext.DataDir,
126-
vm: ext.VM,
126+
extensionID: ext.ID,
127+
manifest: ext.Manifest,
128+
settings: make(map[string]interface{}),
129+
cookieJar: jar,
130+
dataDir: ext.DataDir,
131+
vm: ext.VM,
132+
storageFlushDelay: defaultStorageFlushDelay,
127133
}
128134

129135
// Extension sandbox enforces HTTPS-only domains. Do not apply global

go_backend/extension_runtime_storage.go

Lines changed: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,19 @@ import (
1111
"io"
1212
"os"
1313
"path/filepath"
14+
"reflect"
15+
"time"
1416

1517
"github.com/dop251/goja"
1618
)
1719

1820
// ==================== Storage API ====================
1921

22+
const (
23+
defaultStorageFlushDelay = 400 * time.Millisecond
24+
storageFlushRetryDelay = 2 * time.Second
25+
)
26+
2027
func (r *ExtensionRuntime) getStoragePath() string {
2128
return filepath.Join(r.dataDir, "storage.json")
2229
}
@@ -80,24 +87,90 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
8087
return cloneInterfaceMap(r.storageCache), nil
8188
}
8289

83-
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
84-
storagePath := r.getStoragePath()
85-
data, err := json.MarshalIndent(storage, "", " ")
90+
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
91+
if r.storageClosed {
92+
return
93+
}
94+
if r.storageTimer != nil {
95+
return
96+
}
97+
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
98+
}
99+
100+
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
101+
data, err := json.Marshal(storage)
86102
if err != nil {
87103
return err
88104
}
89105

90-
if err := os.WriteFile(storagePath, data, 0600); err != nil {
91-
return err
106+
r.storageWriteMu.Lock()
107+
defer r.storageWriteMu.Unlock()
108+
109+
return os.WriteFile(r.getStoragePath(), data, 0600)
110+
}
111+
112+
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
113+
if err := r.flushStorageDirty(); err != nil {
114+
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
92115
}
116+
}
93117

118+
func (r *ExtensionRuntime) flushStorageDirty() error {
94119
r.storageMu.Lock()
95-
r.storageCache = cloneInterfaceMap(storage)
96-
r.storageLoaded = true
120+
if r.storageClosed {
121+
r.storageTimer = nil
122+
r.storageMu.Unlock()
123+
return nil
124+
}
125+
if !r.storageDirty {
126+
r.storageTimer = nil
127+
r.storageMu.Unlock()
128+
return nil
129+
}
130+
snapshot := cloneInterfaceMap(r.storageCache)
131+
r.storageDirty = false
132+
r.storageTimer = nil
97133
r.storageMu.Unlock()
134+
135+
if err := r.persistStorageSnapshot(snapshot); err != nil {
136+
r.storageMu.Lock()
137+
r.storageDirty = true
138+
r.queueStorageFlushLocked(storageFlushRetryDelay)
139+
r.storageMu.Unlock()
140+
return err
141+
}
142+
98143
return nil
99144
}
100145

146+
func (r *ExtensionRuntime) flushStorageNow() error {
147+
r.storageMu.Lock()
148+
if r.storageTimer != nil {
149+
r.storageTimer.Stop()
150+
r.storageTimer = nil
151+
}
152+
if !r.storageLoaded || r.storageClosed {
153+
r.storageMu.Unlock()
154+
return nil
155+
}
156+
snapshot := cloneInterfaceMap(r.storageCache)
157+
r.storageDirty = false
158+
r.storageMu.Unlock()
159+
160+
return r.persistStorageSnapshot(snapshot)
161+
}
162+
163+
func (r *ExtensionRuntime) closeStorageFlusher() {
164+
r.storageMu.Lock()
165+
r.storageClosed = true
166+
r.storageDirty = false
167+
if r.storageTimer != nil {
168+
r.storageTimer.Stop()
169+
r.storageTimer = nil
170+
}
171+
r.storageMu.Unlock()
172+
}
173+
101174
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
102175
if len(call.Arguments) < 1 {
103176
return goja.Undefined()
@@ -136,15 +209,21 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
136209
return r.vm.ToValue(false)
137210
}
138211

139-
r.storageMu.RLock()
140-
nextStorage := cloneInterfaceMap(r.storageCache)
141-
r.storageMu.RUnlock()
142-
nextStorage[key] = value
143-
144-
if err := r.saveStorage(nextStorage); err != nil {
145-
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
212+
r.storageMu.Lock()
213+
if r.storageClosed {
214+
r.storageMu.Unlock()
146215
return r.vm.ToValue(false)
147216
}
217+
if existing, exists := r.storageCache[key]; exists {
218+
if reflect.DeepEqual(existing, value) {
219+
r.storageMu.Unlock()
220+
return r.vm.ToValue(true)
221+
}
222+
}
223+
r.storageCache[key] = value
224+
r.storageDirty = true
225+
r.queueStorageFlushLocked(r.storageFlushDelay)
226+
r.storageMu.Unlock()
148227

149228
return r.vm.ToValue(true)
150229
}
@@ -161,15 +240,19 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
161240
return r.vm.ToValue(false)
162241
}
163242

164-
r.storageMu.RLock()
165-
nextStorage := cloneInterfaceMap(r.storageCache)
166-
r.storageMu.RUnlock()
167-
delete(nextStorage, key)
168-
169-
if err := r.saveStorage(nextStorage); err != nil {
170-
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
243+
r.storageMu.Lock()
244+
if r.storageClosed {
245+
r.storageMu.Unlock()
171246
return r.vm.ToValue(false)
172247
}
248+
if _, exists := r.storageCache[key]; !exists {
249+
r.storageMu.Unlock()
250+
return r.vm.ToValue(true)
251+
}
252+
delete(r.storageCache, key)
253+
r.storageDirty = true
254+
r.queueStorageFlushLocked(r.storageFlushDelay)
255+
r.storageMu.Unlock()
173256

174257
return r.vm.ToValue(true)
175258
}

0 commit comments

Comments
 (0)