From 76e5780a64a2dbcb280b8bc0be88cbad50d68464 Mon Sep 17 00:00:00 2001 From: phyce Date: Mon, 6 Jul 2026 10:46:22 +0000 Subject: [PATCH] Guard shared maps in modelManager and config with locking Several readers iterated manager.Engines / entry.Models / config without holding the lock while writers mutated them under a write lock, causing data races and potential concurrent-map-iteration panics (notably in server mode). Add RLock to the affected readers, return copies of maps that escape, take the write lock in Import, and fix the unlock/relock-during-range in restartLocalEngines. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/config/config.go | 41 +++++++++++++++++++++-- app/config/structs.go | 6 ++-- app/tts/modelManager/modelManager.go | 50 ++++++++++++++++++++++------ app/tts/modelManager/structs.go | 2 ++ 4 files changed, 83 insertions(+), 16 deletions(-) diff --git a/app/config/config.go b/app/config/config.go index a415156..61e8aa5 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -123,6 +123,9 @@ func Import(jsonString string) error { return err } + manager.lock.Lock() + defer manager.lock.Unlock() + manager.config = newConfig updatedConfigs, err := json.MarshalIndent(manager.config, "", "\t") @@ -139,22 +142,50 @@ func Import(jsonString string) error { } func Get() Base { - return manager.config + manager.lock.RLock() + defer manager.lock.RUnlock() + + base := manager.config + base.ModelToggles = copyModelToggles(manager.config.ModelToggles) + return base } func GetSettings() Settings { + manager.lock.RLock() + defer manager.lock.RUnlock() + return manager.config.Settings } func GetEngine() Engine { + manager.lock.RLock() + defer manager.lock.RUnlock() + return manager.config.Engine } func GetModelToggles() map[string]bool { - return manager.config.ModelToggles + manager.lock.RLock() + defer manager.lock.RUnlock() + + return copyModelToggles(manager.config.ModelToggles) +} + +func copyModelToggles(source map[string]bool) map[string]bool { + if source == nil { + return nil + } + dst := make(map[string]bool, len(source)) + for key, value := range source { + dst[key] = value + } + return dst } func GetEngineToggles() map[string]map[string]bool { + manager.lock.RLock() + defer manager.lock.RUnlock() + engineToggles := make(map[string]map[string]bool) for key, value := range manager.config.ModelToggles { @@ -176,6 +207,9 @@ func GetEngineToggles() map[string]map[string]bool { } func GetInfo() Info { + manager.lock.RLock() + defer manager.lock.RUnlock() + return manager.config.Info } @@ -185,6 +219,9 @@ func GetValueFromPath(path string) (interface{}, error) { return nil, fmt.Errorf("Invalid path") } + manager.lock.RLock() + defer manager.lock.RUnlock() + current := reflect.ValueOf(manager.config) for _, part := range parts { diff --git a/app/config/structs.go b/app/config/structs.go index 38f84e2..9ef6d82 100644 --- a/app/config/structs.go +++ b/app/config/structs.go @@ -7,9 +7,9 @@ import ( type ConfigManager struct { //defaults map[string]Value `json:"defaults"` - config Base `json:"defaults"` - filePath string `json:"filePath"` - lock sync.Mutex `json:"lock"` + config Base `json:"defaults"` + filePath string `json:"filePath"` + lock sync.RWMutex `json:"lock"` } type Base struct { diff --git a/app/tts/modelManager/modelManager.go b/app/tts/modelManager/modelManager.go index dd2369b..5f80553 100644 --- a/app/tts/modelManager/modelManager.go +++ b/app/tts/modelManager/modelManager.go @@ -114,16 +114,18 @@ func restartEngine(engineName string) { } func restartLocalEngines() { - manager.Lock() - defer manager.Unlock() - + manager.RLock() + var localEngineIDs []string for engineID, entry := range manager.Engines { if entry.Engine.Type == tts.Local { - manager.Unlock() - restartEngine(engineID) - manager.Lock() + localEngineIDs = append(localEngineIDs, engineID) } } + manager.RUnlock() + + for _, engineID := range localEngineIDs { + restartEngine(engineID) + } } func createModelPool(engineID, modelID string) (*ModelPool, error) { @@ -368,7 +370,10 @@ func GetEngineInstance(engineID, modelID string) (tts.Base, func(), bool) { } // Model exists but has no pool (not enabled in toggles). Create one on demand. - if _, modelExists := entry.Engine.Models[modelID]; modelExists { + manager.RLock() + _, modelExists := entry.Engine.Models[modelID] + manager.RUnlock() + if modelExists { manager.Lock() // Double-check after acquiring write lock if existingPool, exists := entry.Models[modelID]; exists { @@ -400,6 +405,9 @@ func GetAllEngines() []tts.Engine { toggles := config.GetEngineToggles() var availableEngines []tts.Engine + manager.RLock() + defer manager.RUnlock() + for engineID, entry := range manager.Engines { filteredEngine := tts.Engine{ ID: engineID, @@ -477,6 +485,9 @@ func GetActiveEngines() []tts.Engine { func GetAllModels() map[string]tts.Model { result := make(map[string]tts.Model) + manager.RLock() + defer manager.RUnlock() + for _, entry := range manager.Engines { for _, model := range entry.Engine.Models { result[model.Engine+":"+model.ID] = model @@ -487,25 +498,42 @@ func GetAllModels() map[string]tts.Model { } func GetModelVoices(engineName string, modelID string) ([]tts.Voice, error) { + manager.RLock() selectedEngine, exists := manager.Engines[engineName] - if !exists { + manager.RUnlock() return nil, response.Err(fmt.Errorf("Engine %s not found", engineName)) } + var instance tts.Base if modelPool, modelExists := selectedEngine.Models[modelID]; modelExists && len(modelPool.Instances) > 0 { - return modelPool.Instances[0].GetVoices(modelID) + instance = modelPool.Instances[0] + } + + var fallbackEngine tts.Base + if instance == nil { + if _, modelExists := selectedEngine.Engine.Models[modelID]; modelExists { + fallbackEngine = selectedEngine.Engine.Engine + } + } + manager.RUnlock() + + if instance != nil { + return instance.GetVoices(modelID) } // Fall back to the engine directly (handles models not yet pooled/enabled) - if _, modelExists := selectedEngine.Engine.Models[modelID]; modelExists { - return selectedEngine.Engine.Engine.GetVoices(modelID) + if fallbackEngine != nil { + return fallbackEngine.GetVoices(modelID) } return nil, response.Err(fmt.Errorf("Model %s not found for engine %s", modelID, engineName)) } func GetInstanceCount(engineID string, modelID string) int { + manager.RLock() + defer manager.RUnlock() + if selectedEngine, exists := manager.Engines[engineID]; exists { if modelPool, modelExists := selectedEngine.Models[modelID]; modelExists { return len(modelPool.Instances) diff --git a/app/tts/modelManager/structs.go b/app/tts/modelManager/structs.go index d2c68ac..f838f56 100644 --- a/app/tts/modelManager/structs.go +++ b/app/tts/modelManager/structs.go @@ -29,7 +29,9 @@ type EngineEntry struct { } func (entry *EngineEntry) GetModelInstance(modelID string) (tts.Base, *ModelPool, bool) { + manager.RLock() pool, exists := entry.Models[modelID] + manager.RUnlock() if !exists { return nil, nil, false }