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" "io/fs"
"os" "os"
"strings" "strings"
"sync/atomic"
"github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -15,10 +16,11 @@ import (
"golang.org/x/text/language" "golang.org/x/text/language"
) )
// Each is published whole: InitLocalizer runs again (Telegram bot start) while requests read them.
var ( var (
i18nBundle *i18n.Bundle i18nBundle atomic.Pointer[i18n.Bundle]
LocalizerWeb *i18n.Localizer localizerWeb atomic.Pointer[i18n.Localizer]
LocalizerBot *i18n.Localizer localizerBot atomic.Pointer[i18n.Localizer]
) )
// I18nType represents the type of interface for internationalization. // I18nType represents the type of interface for internationalization.
@@ -37,13 +39,14 @@ type SettingService interface {
// InitLocalizer initializes the internationalization system with embedded translation files. // InitLocalizer initializes the internationalization system with embedded translation files.
func InitLocalizer(i18nFS fs.FS, settingService SettingService) error { func InitLocalizer(i18nFS fs.FS, settingService SettingService) error {
// set default bundle to English // set default bundle to English
i18nBundle = i18n.NewBundle(language.MustParse("en-US")) bundle := i18n.NewBundle(language.MustParse("en-US"))
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal) bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
// parse files // parse files
if err := parseTranslationFiles(i18nFS, i18nBundle); err != nil { if err := parseTranslationFiles(i18nFS, bundle); err != nil {
return err return err
} }
i18nBundle.Store(bundle)
// setup bot locale // setup bot locale
if err := initTGBotLocalizer(settingService); err != nil { if err := initTGBotLocalizer(settingService); err != nil {
@@ -77,9 +80,9 @@ func I18n(i18nType I18nType, key string, params ...string) string {
switch i18nType { switch i18nType {
case "bot": case "bot":
localizer = LocalizerBot localizer = localizerBot.Load()
case "web": case "web":
localizer = LocalizerWeb localizer = localizerWeb.Load()
default: default:
logger.Errorf("Invalid type for I18n: %s", i18nType) logger.Errorf("Invalid type for I18n: %s", i18nType)
return "" 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. // LocalizerFor returns a new Localizer for the given language tag using the global bundle.
func LocalizerFor(lang string) *i18n.Localizer { func LocalizerFor(lang string) *i18n.Localizer {
if i18nBundle == nil { bundle := i18nBundle.Load()
if bundle == nil {
return 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. // I18nForLang retrieves a localized message for a specific language tag with optional params.
@@ -136,7 +140,7 @@ func initTGBotLocalizer(settingService SettingService) error {
return err return err
} }
LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang) localizerBot.Store(i18n.NewLocalizer(i18nBundle.Load(), botLang))
return nil return nil
} }
@@ -147,13 +151,17 @@ func initTGBotLocalizer(settingService SettingService) error {
func LocalizerMiddleware() gin.HandlerFunc { func LocalizerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// Ensure bundle is initialized so creating a Localizer won't panic // Ensure bundle is initialized so creating a Localizer won't panic
if i18nBundle == nil { bundle := i18nBundle.Load()
i18nBundle = i18n.NewBundle(language.MustParse("en-US")) if bundle == nil {
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal) bundle = i18n.NewBundle(language.MustParse("en-US"))
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
// Try lazy-load from disk when running sub server without InitLocalizer // 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) logger.Warning("i18n lazy load failed:", err)
} }
if !i18nBundle.CompareAndSwap(nil, bundle) {
bundle = i18nBundle.Load()
}
} }
var lang string var lang string
@@ -163,9 +171,10 @@ func LocalizerMiddleware() gin.HandlerFunc {
lang = c.GetHeader("Accept-Language") 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.Set("I18n", I18n)
c.Next() c.Next()
} }
@@ -216,3 +225,8 @@ func parseTranslationFiles(i18nFS fs.FS, i18nBundle *i18n.Bundle) error {
return nil 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)
}
+48
View File
@@ -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())
}
}
+2 -3
View File
@@ -636,9 +636,8 @@ func localizeWithRealBundle(t *testing.T) {
if _, err := bundle.ParseMessageFileBytes(data, path); err != nil { if _, err := bundle.ParseMessageFileBytes(data, path); err != nil {
t.Fatalf("parse %s: %v", path, err) t.Fatalf("parse %s: %v", path, err)
} }
orig := locale.LocalizerBot orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US"))
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") t.Cleanup(func() { locale.SwapBotLocalizer(orig) })
t.Cleanup(func() { locale.LocalizerBot = orig })
} }
// seeding a client bound to a Telegram user, which the client screens key on // seeding a client bound to a Telegram user, which the client screens key on
@@ -121,9 +121,8 @@ func broadcastLocalizer(t *testing.T) {
&i18n.Message{ID: "tgbot.messages.broadcastCanceled", Other: "canceled"}, &i18n.Message{ID: "tgbot.messages.broadcastCanceled", Other: "canceled"},
&i18n.Message{ID: "tgbot.messages.broadcastUnreachable", Other: "ℹ️ {{ .Count }} recipients cannot be messaged — ask them to press Start."}, &i18n.Message{ID: "tgbot.messages.broadcastUnreachable", Other: "ℹ️ {{ .Count }} recipients cannot be messaged — ask them to press Start."},
) )
orig := locale.LocalizerBot orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US"))
t.Cleanup(func() { locale.LocalizerBot = orig }) t.Cleanup(func() { locale.SwapBotLocalizer(orig) })
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
} }
func createBroadcastInbound(t *testing.T, tag, settings string) { func createBroadcastInbound(t *testing.T, tag, settings string) {
@@ -27,9 +27,8 @@ func clientInfoLocalizer(t *testing.T) {
&i18n.Message{ID: "tgbot.messages.expire", Other: "Expire Date: {{ .Time }}\r\n"}, &i18n.Message{ID: "tgbot.messages.expire", Other: "Expire Date: {{ .Time }}\r\n"},
&i18n.Message{ID: "tgbot.wentWrong", Other: "went wrong"}, &i18n.Message{ID: "tgbot.wentWrong", Other: "went wrong"},
) )
orig := locale.LocalizerBot orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US"))
t.Cleanup(func() { locale.LocalizerBot = orig }) t.Cleanup(func() { locale.SwapBotLocalizer(orig) })
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
} }
// Regression test: a start-after-first-use client is stored as a negative duration, // Regression test: a start-after-first-use client is stored as a negative duration,
@@ -52,9 +52,8 @@ func draftLocalizer(t *testing.T, msgs ...*i18n.Message) {
bundle := i18n.NewBundle(language.MustParse("en-US")) bundle := i18n.NewBundle(language.MustParse("en-US"))
bundle.RegisterUnmarshalFunc("json", json.Unmarshal) bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
_ = bundle.AddMessages(language.MustParse("en-US"), msgs...) _ = bundle.AddMessages(language.MustParse("en-US"), msgs...)
orig := locale.LocalizerBot orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US"))
t.Cleanup(func() { locale.LocalizerBot = orig }) t.Cleanup(func() { locale.SwapBotLocalizer(orig) })
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
} }
// Regression test: the wizard's own prompts are HTML-parsed as well, so the // Regression test: the wizard's own prompts are HTML-parsed as well, so the
@@ -291,9 +291,8 @@ func TestNotifyExhaustedLooksUpEachIDOnce(t *testing.T) {
func TestServerUsageInfoShowsUnavailableUptime(t *testing.T) { func TestServerUsageInfoShowsUnavailableUptime(t *testing.T) {
bundle := i18n.NewBundle(language.English) bundle := i18n.NewBundle(language.English)
_ = bundle.AddMessages(language.English, &i18n.Message{ID: "tgbot.messages.serverUpTime", Other: "Uptime: {{.UpTime}} {{.Unit}}\n"}, &i18n.Message{ID: "tgbot.days", Other: "days"}) _ = bundle.AddMessages(language.English, &i18n.Message{ID: "tgbot.messages.serverUpTime", Other: "Uptime: {{.UpTime}} {{.Unit}}\n"}, &i18n.Message{ID: "tgbot.days", Other: "days"})
oldLocale := locale.LocalizerBot oldLocale := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en"))
locale.LocalizerBot = i18n.NewLocalizer(bundle, "en") t.Cleanup(func() { locale.SwapBotLocalizer(oldLocale) })
t.Cleanup(func() { locale.LocalizerBot = oldLocale })
statusCache.mutex.Lock() statusCache.mutex.Lock()
oldStatus, oldTime := statusCache.data, statusCache.timestamp oldStatus, oldTime := statusCache.data, statusCache.timestamp
statusCache.mutex.Unlock() statusCache.mutex.Unlock()