From 3f6c06add5e1ed8f9f01c76db059574a06350f7f Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:10:14 +0000 Subject: [PATCH] fix(i18n): release service lock before handler callbacks Co-Authored-By: Virgil --- service.go | 40 +++++++++++++++++++++++----------------- service_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/service.go b/service.go index a650d3c..471a671 100644 --- a/service.go +++ b/service.go @@ -364,19 +364,25 @@ func (s *Service) Handlers() []KeyHandler { // T("i18n.fail.delete", "file") // "Failed to delete file" func (s *Service) T(messageID string, args ...any) string { s.mu.RLock() - defer s.mu.RUnlock() - result := RunHandlerChain(s.handlers, messageID, args, func() string { + handlers := append([]KeyHandler(nil), s.handlers...) + debug := s.debug + s.mu.RUnlock() + + result := RunHandlerChain(handlers, messageID, args, func() string { var data any if len(args) > 0 { data = args[0] } - text := s.resolveWithFallback(messageID, data) + + s.mu.RLock() + text := s.resolveWithFallbackLocked(messageID, data) + s.mu.RUnlock() if text == "" { return s.handleMissingKey(messageID, args) } return text }) - if s.debug { + if debug { return debugFormat(messageID, result) } return result @@ -390,55 +396,55 @@ func (s *Service) Translate(messageID string, args ...any) core.Result { // resolveDirect performs exact-key lookup in the current language, its base // language tag, and then the configured fallback language. -func (s *Service) resolveDirect(messageID string, data any) string { - if text := s.tryResolve(s.currentLang, messageID, data); text != "" { +func (s *Service) resolveDirectLocked(messageID string, data any) string { + if text := s.tryResolveLocked(s.currentLang, messageID, data); text != "" { return text } if base := baseLanguageTag(s.currentLang); base != "" && base != s.currentLang { - if text := s.tryResolve(base, messageID, data); text != "" { + if text := s.tryResolveLocked(base, messageID, data); text != "" { return text } } - if text := s.tryResolve(s.fallbackLang, messageID, data); text != "" { + if text := s.tryResolveLocked(s.fallbackLang, messageID, data); text != "" { return text } if base := baseLanguageTag(s.fallbackLang); base != "" && base != s.fallbackLang { - return s.tryResolve(base, messageID, data) + return s.tryResolveLocked(base, messageID, data) } return "" } -func (s *Service) resolveWithFallback(messageID string, data any) string { - if text := s.resolveDirect(messageID, data); text != "" { +func (s *Service) resolveWithFallbackLocked(messageID string, data any) string { + if text := s.resolveDirectLocked(messageID, data); text != "" { return text } if core.Contains(messageID, ".") { parts := core.Split(messageID, ".") verb := parts[len(parts)-1] commonKey := "common.action." + verb - if text := s.resolveDirect(commonKey, data); text != "" { + if text := s.resolveDirectLocked(commonKey, data); text != "" { return text } commonKey = "common." + verb - if text := s.resolveDirect(commonKey, data); text != "" { + if text := s.resolveDirectLocked(commonKey, data); text != "" { return text } } return "" } -func (s *Service) tryResolve(lang, key string, data any) string { +func (s *Service) tryResolveLocked(lang, key string, data any) string { context, gender, location, formality := s.getEffectiveContextGenderLocationAndFormality(data) extra := s.getEffectiveContextExtra(data) for _, lookupKey := range lookupVariants(key, context, gender, location, formality, extra) { - if text := s.resolveMessage(lang, lookupKey, data); text != "" { + if text := s.resolveMessageLocked(lang, lookupKey, data); text != "" { return text } } return "" } -func (s *Service) resolveMessage(lang, key string, data any) string { +func (s *Service) resolveMessageLocked(lang, key string, data any) string { msg, ok := s.getMessage(lang, key) if !ok { return "" @@ -770,7 +776,7 @@ func (s *Service) Raw(messageID string, args ...any) string { if len(args) > 0 { data = args[0] } - text := s.resolveDirect(messageID, data) + text := s.resolveDirectLocked(messageID, data) if text == "" { return s.handleMissingKey(messageID, args) } diff --git a/service_test.go b/service_test.go index fca3ae4..370f719 100644 --- a/service_test.go +++ b/service_test.go @@ -3,6 +3,7 @@ package i18n import ( "testing" "testing/fstest" + "time" "dappco.re/go/core" ) @@ -17,6 +18,21 @@ func (messageBaseFallbackLoader) Load(lang string) (map[string]Message, *Grammar return map[string]Message{}, nil, nil } +type serviceMutatingHandler struct { + svc *Service +} + +func (h serviceMutatingHandler) Match(key string) bool { + return key == "custom.mutate.language" +} + +func (h serviceMutatingHandler) Handle(key string, args []any, next func() string) string { + if h.svc != nil { + _ = h.svc.SetLanguage("fr") + } + return "mutated" +} + func TestNewService(t *testing.T) { svc, err := New() if err != nil { @@ -218,6 +234,33 @@ func TestServiceRaw(t *testing.T) { } } +func TestServiceT_CustomHandlerCanMutateService(t *testing.T) { + svc, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + + svc.PrependHandler(serviceMutatingHandler{svc: svc}) + + done := make(chan string, 1) + go func() { + done <- svc.T("custom.mutate.language") + }() + + select { + case got := <-done: + if got != "mutated" { + t.Fatalf("T(custom.mutate.language) = %q, want %q", got, "mutated") + } + case <-time.After(2 * time.Second): + t.Fatal("T(custom.mutate.language) timed out while handler mutated service state") + } + + if got := svc.Language(); got != "fr" { + t.Fatalf("Language() = %q, want %q", got, "fr") + } +} + func TestServiceRaw_DoesNotUseCommonFallbacks(t *testing.T) { svc, err := New() if err != nil { -- 2.45.3