From f1e1b0fb0ca84f2463c5f019751f212d79cdbae8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 31 Oct 2026 09:11:54 +0000 Subject: [PATCH] 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. --- internal/web/locale/locale.go | 48 ++++++++++++------- internal/web/locale/locale_test.go | 48 +++++++++++++++++++ internal/web/service/tgbot/screens_test.go | 5 +- .../web/service/tgbot/tgbot_broadcast_test.go | 5 +- .../service/tgbot/tgbot_client_expiry_test.go | 5 +- .../service/tgbot/tgbot_draft_render_test.go | 5 +- .../tgbot/tgbot_report_batching_test.go | 5 +- 7 files changed, 89 insertions(+), 32 deletions(-) create mode 100644 internal/web/locale/locale_test.go diff --git a/internal/web/locale/locale.go b/internal/web/locale/locale.go index c3a0a0c37..a5d48d9a6 100644 --- a/internal/web/locale/locale.go +++ b/internal/web/locale/locale.go @@ -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) +} diff --git a/internal/web/locale/locale_test.go b/internal/web/locale/locale_test.go new file mode 100644 index 000000000..aadad2dca --- /dev/null +++ b/internal/web/locale/locale_test.go @@ -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()) + } +} diff --git a/internal/web/service/tgbot/screens_test.go b/internal/web/service/tgbot/screens_test.go index 97fd648fb..34ccb68ae 100644 --- a/internal/web/service/tgbot/screens_test.go +++ b/internal/web/service/tgbot/screens_test.go @@ -636,9 +636,8 @@ func localizeWithRealBundle(t *testing.T) { if _, err := bundle.ParseMessageFileBytes(data, path); err != nil { t.Fatalf("parse %s: %v", path, err) } - orig := locale.LocalizerBot - locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") - t.Cleanup(func() { locale.LocalizerBot = orig }) + orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US")) + t.Cleanup(func() { locale.SwapBotLocalizer(orig) }) } // seeding a client bound to a Telegram user, which the client screens key on diff --git a/internal/web/service/tgbot/tgbot_broadcast_test.go b/internal/web/service/tgbot/tgbot_broadcast_test.go index 9d3487d9f..5d80bed38 100644 --- a/internal/web/service/tgbot/tgbot_broadcast_test.go +++ b/internal/web/service/tgbot/tgbot_broadcast_test.go @@ -121,9 +121,8 @@ func broadcastLocalizer(t *testing.T) { &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."}, ) - orig := locale.LocalizerBot - t.Cleanup(func() { locale.LocalizerBot = orig }) - locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") + orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US")) + t.Cleanup(func() { locale.SwapBotLocalizer(orig) }) } func createBroadcastInbound(t *testing.T, tag, settings string) { diff --git a/internal/web/service/tgbot/tgbot_client_expiry_test.go b/internal/web/service/tgbot/tgbot_client_expiry_test.go index efa9b8c92..1ac6c41ce 100644 --- a/internal/web/service/tgbot/tgbot_client_expiry_test.go +++ b/internal/web/service/tgbot/tgbot_client_expiry_test.go @@ -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.wentWrong", Other: "went wrong"}, ) - orig := locale.LocalizerBot - t.Cleanup(func() { locale.LocalizerBot = orig }) - locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") + orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US")) + t.Cleanup(func() { locale.SwapBotLocalizer(orig) }) } // Regression test: a start-after-first-use client is stored as a negative duration, diff --git a/internal/web/service/tgbot/tgbot_draft_render_test.go b/internal/web/service/tgbot/tgbot_draft_render_test.go index aacc14be2..9871bc928 100644 --- a/internal/web/service/tgbot/tgbot_draft_render_test.go +++ b/internal/web/service/tgbot/tgbot_draft_render_test.go @@ -52,9 +52,8 @@ func draftLocalizer(t *testing.T, msgs ...*i18n.Message) { bundle := i18n.NewBundle(language.MustParse("en-US")) bundle.RegisterUnmarshalFunc("json", json.Unmarshal) _ = bundle.AddMessages(language.MustParse("en-US"), msgs...) - orig := locale.LocalizerBot - t.Cleanup(func() { locale.LocalizerBot = orig }) - locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US") + orig := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en-US")) + t.Cleanup(func() { locale.SwapBotLocalizer(orig) }) } // Regression test: the wizard's own prompts are HTML-parsed as well, so the diff --git a/internal/web/service/tgbot/tgbot_report_batching_test.go b/internal/web/service/tgbot/tgbot_report_batching_test.go index 3adcf9c6a..ba0166ba9 100644 --- a/internal/web/service/tgbot/tgbot_report_batching_test.go +++ b/internal/web/service/tgbot/tgbot_report_batching_test.go @@ -291,9 +291,8 @@ func TestNotifyExhaustedLooksUpEachIDOnce(t *testing.T) { func TestServerUsageInfoShowsUnavailableUptime(t *testing.T) { 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"}) - oldLocale := locale.LocalizerBot - locale.LocalizerBot = i18n.NewLocalizer(bundle, "en") - t.Cleanup(func() { locale.LocalizerBot = oldLocale }) + oldLocale := locale.SwapBotLocalizer(i18n.NewLocalizer(bundle, "en")) + t.Cleanup(func() { locale.SwapBotLocalizer(oldLocale) }) statusCache.mutex.Lock() oldStatus, oldTime := statusCache.data, statusCache.timestamp statusCache.mutex.Unlock()