Files
Fred 0e26f12426 fix(locale): protect translation catalog from concurrent map writes
getTranslationDict lazily populates the package-level defaultCatalog
map and runs on concurrent request goroutines via template functions,
flash messages, and error translation. Two concurrent requests for
languages not yet cached triggered Go's fatal "concurrent map writes"
and killed the daemon.

Guard the catalog with a sync.RWMutex using double-checked locking so
each language is loaded once. A failed load is no longer cached as an
empty dictionary, so unknown languages now return the error on every
call; both callers treat an error and an empty dictionary identically,
so rendered output is unchanged.

Add a regression test exercising concurrent lazy population; it fails
under the race detector on the previous implementation.
2026-07-21 20:54:36 -07:00

107 lines
2.6 KiB
Go

// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package locale // import "miniflux.app/v2/internal/locale"
import (
"embed"
"encoding/json"
"fmt"
"sync"
)
type translationDict struct {
singulars map[string]string
plurals map[string][]string
}
type catalog map[string]translationDict
// defaultCatalog is populated lazily by getTranslationDict, which runs on
// concurrent request goroutines, so every access must hold defaultCatalogMutex.
var (
defaultCatalog = make(catalog, len(AvailableLanguages))
defaultCatalogMutex sync.RWMutex
)
//go:embed translations/*.json
var translationFiles embed.FS
func getTranslationDict(language string) (translationDict, error) {
defaultCatalogMutex.RLock()
dict, found := defaultCatalog[language]
defaultCatalogMutex.RUnlock()
if found {
return dict, nil
}
defaultCatalogMutex.Lock()
defer defaultCatalogMutex.Unlock()
if dict, found := defaultCatalog[language]; found {
return dict, nil
}
dict, err := loadTranslationFile(language)
if err != nil {
return translationDict{}, err
}
defaultCatalog[language] = dict
return dict, nil
}
func loadTranslationFile(language string) (translationDict, error) {
translationFileData, err := translationFiles.ReadFile("translations/" + language + ".json")
if err != nil {
return translationDict{}, err
}
translationMessages, err := parseTranslationMessages(translationFileData)
if err != nil {
return translationDict{}, err
}
return translationMessages, nil
}
func (t *translationDict) UnmarshalJSON(data []byte) error {
var tmpMap map[string]any
err := json.Unmarshal(data, &tmpMap)
if err != nil {
return err
}
m := translationDict{
singulars: make(map[string]string),
plurals: make(map[string][]string),
}
for key, value := range tmpMap {
switch vtype := value.(type) {
case string:
m.singulars[key] = vtype
case []any:
for _, translation := range vtype {
if translationStr, ok := translation.(string); ok {
m.plurals[key] = append(m.plurals[key], translationStr)
} else {
return fmt.Errorf("invalid type for translation in an array: %v", translation)
}
}
default:
return fmt.Errorf("invalid type (%T) for translation: %v", vtype, value)
}
}
*t = m
return nil
}
func parseTranslationMessages(data []byte) (translationDict, error) {
var translationMessages translationDict
if err := json.Unmarshal(data, &translationMessages); err != nil {
return translationDict{}, fmt.Errorf(`invalid translation file: %w`, err)
}
return translationMessages, nil
}