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:
MHSanaei
2026-10-31 09:11:54 +00:00
parent e3736564a3
commit f1e1b0fb0c
7 changed files with 89 additions and 32 deletions
+31 -17
View File
@@ -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)
}