mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
fix(i18n): publish the translation bundle only once it is complete
InitLocalizer filled the shared bundle in place, and the Telegram bot runs it again while the panel is serving, so a request could read the bundle's maps while they were being written and the process died on a concurrent map access. Build the bundle privately and publish it, and the bot and web localizers, through atomic pointers; the lazy load in the middleware publishes only if nothing else did first.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
@@ -15,10 +16,11 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Each is published whole: InitLocalizer runs again (Telegram bot start) while requests read them.
|
||||
var (
|
||||
i18nBundle *i18n.Bundle
|
||||
LocalizerWeb *i18n.Localizer
|
||||
LocalizerBot *i18n.Localizer
|
||||
i18nBundle atomic.Pointer[i18n.Bundle]
|
||||
localizerWeb atomic.Pointer[i18n.Localizer]
|
||||
localizerBot atomic.Pointer[i18n.Localizer]
|
||||
)
|
||||
|
||||
// I18nType represents the type of interface for internationalization.
|
||||
@@ -37,13 +39,14 @@ type SettingService interface {
|
||||
// InitLocalizer initializes the internationalization system with embedded translation files.
|
||||
func InitLocalizer(i18nFS fs.FS, settingService SettingService) error {
|
||||
// set default bundle to English
|
||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
bundle := i18n.NewBundle(language.MustParse("en-US"))
|
||||
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
|
||||
// parse files
|
||||
if err := parseTranslationFiles(i18nFS, i18nBundle); err != nil {
|
||||
if err := parseTranslationFiles(i18nFS, bundle); err != nil {
|
||||
return err
|
||||
}
|
||||
i18nBundle.Store(bundle)
|
||||
|
||||
// setup bot locale
|
||||
if err := initTGBotLocalizer(settingService); err != nil {
|
||||
@@ -77,9 +80,9 @@ func I18n(i18nType I18nType, key string, params ...string) string {
|
||||
|
||||
switch i18nType {
|
||||
case "bot":
|
||||
localizer = LocalizerBot
|
||||
localizer = localizerBot.Load()
|
||||
case "web":
|
||||
localizer = LocalizerWeb
|
||||
localizer = localizerWeb.Load()
|
||||
default:
|
||||
logger.Errorf("Invalid type for I18n: %s", i18nType)
|
||||
return ""
|
||||
@@ -106,10 +109,11 @@ func I18n(i18nType I18nType, key string, params ...string) string {
|
||||
|
||||
// LocalizerFor returns a new Localizer for the given language tag using the global bundle.
|
||||
func LocalizerFor(lang string) *i18n.Localizer {
|
||||
if i18nBundle == nil {
|
||||
bundle := i18nBundle.Load()
|
||||
if bundle == nil {
|
||||
return nil
|
||||
}
|
||||
return i18n.NewLocalizer(i18nBundle, lang)
|
||||
return i18n.NewLocalizer(bundle, lang)
|
||||
}
|
||||
|
||||
// I18nForLang retrieves a localized message for a specific language tag with optional params.
|
||||
@@ -136,7 +140,7 @@ func initTGBotLocalizer(settingService SettingService) error {
|
||||
return err
|
||||
}
|
||||
|
||||
LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang)
|
||||
localizerBot.Store(i18n.NewLocalizer(i18nBundle.Load(), botLang))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,13 +151,17 @@ func initTGBotLocalizer(settingService SettingService) error {
|
||||
func LocalizerMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Ensure bundle is initialized so creating a Localizer won't panic
|
||||
if i18nBundle == nil {
|
||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
bundle := i18nBundle.Load()
|
||||
if bundle == nil {
|
||||
bundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||
// Try lazy-load from disk when running sub server without InitLocalizer
|
||||
if err := loadTranslationsFromDisk(i18nBundle); err != nil {
|
||||
if err := loadTranslationsFromDisk(bundle); err != nil {
|
||||
logger.Warning("i18n lazy load failed:", err)
|
||||
}
|
||||
if !i18nBundle.CompareAndSwap(nil, bundle) {
|
||||
bundle = i18nBundle.Load()
|
||||
}
|
||||
}
|
||||
var lang string
|
||||
|
||||
@@ -163,9 +171,10 @@ func LocalizerMiddleware() gin.HandlerFunc {
|
||||
lang = c.GetHeader("Accept-Language")
|
||||
}
|
||||
|
||||
LocalizerWeb = i18n.NewLocalizer(i18nBundle, lang)
|
||||
localizer := i18n.NewLocalizer(bundle, lang)
|
||||
localizerWeb.Store(localizer)
|
||||
|
||||
c.Set("localizer", LocalizerWeb)
|
||||
c.Set("localizer", localizer)
|
||||
c.Set("I18n", I18n)
|
||||
c.Next()
|
||||
}
|
||||
@@ -216,3 +225,8 @@ func parseTranslationFiles(i18nFS fs.FS, i18nBundle *i18n.Bundle) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwapBotLocalizer installs l as the bot localizer and returns the previous one.
|
||||
func SwapBotLocalizer(l *i18n.Localizer) *i18n.Localizer {
|
||||
return localizerBot.Swap(l)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type fixedLang string
|
||||
|
||||
func (l fixedLang) GetTgLang() (string, error) { return string(l), nil }
|
||||
|
||||
// The Telegram bot re-runs InitLocalizer while the panel serves requests; a request must
|
||||
// never read the bundle while it is being filled (a concurrent map access kills the process).
|
||||
func TestInitLocalizerWhileServingRequests(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
translations := fstest.MapFS{"translation/en_US.json": {Data: []byte(`{"hello": "Hello"}`)}}
|
||||
engine := gin.New()
|
||||
engine.Use(LocalizerMiddleware())
|
||||
engine.GET("/", func(c *gin.Context) { c.String(http.StatusOK, I18nForLang("en-US", "hello")) })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
for range 200 {
|
||||
if err := InitLocalizer(translations, fixedLang("en-US")); err != nil {
|
||||
t.Errorf("InitLocalizer: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
for range 4 {
|
||||
wg.Go(func() {
|
||||
for range 200 {
|
||||
engine.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Body.String() != "Hello" {
|
||||
t.Fatalf("after re-initialisation the bundle answers %q, want Hello", rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user