From 06adb4b94ee482c45833214ca82bdbd23f6e61a8 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 21:40:53 +0000 Subject: [PATCH] fix(config): lock the chats limits that background goroutines read go test -race on core/http/endpoints/localai reported data races between POST /api/settings and the goroutines that read the result: ApplyRuntimeSettings wrote chats_egress_audit_days and the chats_loop_* limits as plain fields while the audit prune and the loop runner read them through the Effective getters. Guard those six fields with one RWMutex, taken by the getters and by the registry setters. A new spec applies the settings while four goroutines read them and fails under -race without the lock. Assisted-by: Claude:claude-sonnet-5-5 go-test --- core/config/application_config.go | 27 +++++++++++++ core/config/chats_limits_race_test.go | 51 ++++++++++++++++++++++++ core/config/runtime_settings_registry.go | 32 ++++++++++++--- 3 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 core/config/chats_limits_race_test.go diff --git a/core/config/application_config.go b/core/config/application_config.go index 306fbe78f1..d84d0405eb 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "regexp" + "sync" "sync/atomic" "time" @@ -425,6 +426,12 @@ type ApplicationConfig struct { ChatsEgressAllowedHosts string ChatsEgressAuditDays int + // chatsLimitsMu guards ChatsEgressAuditDays and the chats_loop_* limits. + // Background goroutines (the audit prune, the loop runner) read them + // while a settings POST rewrites them; use the Effective getters and the + // registry setters, which hold it. + chatsLimitsMu sync.RWMutex + // ChatsMaxIterations is the step limit of the tool loop of one chat turn: // how many rounds of tool calls the model may make before it must answer. // A turn that reaches it ends as paused, and the person can let it go on. @@ -1602,6 +1609,8 @@ const ( // EffectiveChatsLoopMinInterval is chats_loop_min_interval as it applies now. func (o *ApplicationConfig) EffectiveChatsLoopMinInterval() time.Duration { + o.chatsLimitsMu.RLock() + defer o.chatsLimitsMu.RUnlock() if d := o.ChatsLoopMinInterval; d >= MinChatsLoopInterval && d <= MaxChatsLoopInterval { return d } @@ -1618,21 +1627,29 @@ func inRange(n, hi, def int) int { // EffectiveChatsLoopMaxRuns is chats_loop_max_runs as it applies now. func (o *ApplicationConfig) EffectiveChatsLoopMaxRuns() int { + o.chatsLimitsMu.RLock() + defer o.chatsLimitsMu.RUnlock() return inRange(o.ChatsLoopMaxRuns, MaxChatsLoopMaxRuns, DefaultChatsLoopMaxRuns) } // EffectiveChatsLoopMaxPerChat is chats_loop_max_per_chat as it applies now. func (o *ApplicationConfig) EffectiveChatsLoopMaxPerChat() int { + o.chatsLimitsMu.RLock() + defer o.chatsLimitsMu.RUnlock() return inRange(o.ChatsLoopMaxPerChat, MaxChatsLoopMaxPerChat, DefaultChatsLoopMaxPerChat) } // EffectiveChatsLoopMaxPerUser is chats_loop_max_per_user as it applies now. func (o *ApplicationConfig) EffectiveChatsLoopMaxPerUser() int { + o.chatsLimitsMu.RLock() + defer o.chatsLimitsMu.RUnlock() return inRange(o.ChatsLoopMaxPerUser, MaxChatsLoopMaxPerUser, DefaultChatsLoopMaxPerUser) } // EffectiveChatsLoopDailyRuns is chats_loop_daily_runs as it applies now. func (o *ApplicationConfig) EffectiveChatsLoopDailyRuns() int { + o.chatsLimitsMu.RLock() + defer o.chatsLimitsMu.RUnlock() return inRange(o.ChatsLoopDailyRuns, MaxChatsLoopDailyRuns, DefaultChatsLoopDailyRuns) } @@ -1656,8 +1673,18 @@ func (o *ApplicationConfig) EffectiveChatsIndexMaxTextBytes() int { // line while chats_egress_audit_days is unset. const DefaultChatsEgressAuditDays = 30 +// setChatsEgressAuditDays stores chats_egress_audit_days under the lock that +// the prune goroutine's reads take. +func (o *ApplicationConfig) setChatsEgressAuditDays(v int) { + o.chatsLimitsMu.Lock() + o.ChatsEgressAuditDays = v + o.chatsLimitsMu.Unlock() +} + // EffectiveChatsEgressAuditDays is chats_egress_audit_days as it applies now. func (o *ApplicationConfig) EffectiveChatsEgressAuditDays() int { + o.chatsLimitsMu.RLock() + defer o.chatsLimitsMu.RUnlock() if o.ChatsEgressAuditDays < 1 || o.ChatsEgressAuditDays > 365 { return DefaultChatsEgressAuditDays } diff --git a/core/config/chats_limits_race_test.go b/core/config/chats_limits_race_test.go new file mode 100644 index 0000000000..1fcffcb4c5 --- /dev/null +++ b/core/config/chats_limits_race_test.go @@ -0,0 +1,51 @@ +package config_test + +import ( + "sync" + + "github.com/mudler/LocalAI/core/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The prune and loop goroutines read these limits while a settings POST +// rewrites them. Under -race this fails when a getter or a setter skips the lock. +var _ = Describe("chats limits read while settings are applied", func() { + It("does not race", func() { + o := &config.ApplicationConfig{} + days, runs, perChat, perUser, daily := 10, 5, 2, 3, 4 + interval := "2m" + rs := &config.RuntimeSettings{ + ChatsEgressAuditDays: &days, ChatsLoopMaxRuns: &runs, ChatsLoopMaxPerChat: &perChat, + ChatsLoopMaxPerUser: &perUser, ChatsLoopDailyRuns: &daily, ChatsLoopMinInterval: &interval, + } + var wg sync.WaitGroup + stop := make(chan struct{}) + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + _ = o.EffectiveChatsEgressAuditDays() + _ = o.EffectiveChatsLoopMinInterval() + _ = o.EffectiveChatsLoopMaxRuns() + _ = o.EffectiveChatsLoopMaxPerChat() + _ = o.EffectiveChatsLoopMaxPerUser() + _ = o.EffectiveChatsLoopDailyRuns() + } + }() + } + for range 200 { + o.ApplyRuntimeSettings(rs) + } + close(stop) + wg.Wait() + Expect(o.EffectiveChatsEgressAuditDays()).To(Equal(10)) + Expect(o.EffectiveChatsLoopMaxRuns()).To(Equal(5)) + }) +}) diff --git a/core/config/runtime_settings_registry.go b/core/config/runtime_settings_registry.go index c77da71120..e150e79491 100644 --- a/core/config/runtime_settings_registry.go +++ b/core/config/runtime_settings_registry.go @@ -636,13 +636,13 @@ var runtimeSettingsFields = []fieldSpec{ withApply(field("chats_egress_audit_days", func(s *RuntimeSettings) **int { return &s.ChatsEgressAuditDays }, func(o *ApplicationConfig) int { return o.EffectiveChatsEgressAuditDays() }, - func(o *ApplicationConfig, v int) { o.ChatsEgressAuditDays = v }), + func(o *ApplicationConfig, v int) { o.setChatsEgressAuditDays(v) }), func(o *ApplicationConfig, s *RuntimeSettings) bool { if v := *s.ChatsEgressAuditDays; v < 1 || v > 365 { xlog.Warn("invalid chats_egress_audit_days in runtime settings", "value", v) return false } - o.ChatsEgressAuditDays = *s.ChatsEgressAuditDays + o.setChatsEgressAuditDays(*s.ChatsEgressAuditDays) return true }), @@ -695,7 +695,9 @@ var runtimeSettingsFields = []fieldSpec{ func(o *ApplicationConfig) string { return o.EffectiveChatsLoopMinInterval().String() }, func(o *ApplicationConfig, v string) { if d, err := time.ParseDuration(v); err == nil { + o.chatsLimitsMu.Lock() o.ChatsLoopMinInterval = d + o.chatsLimitsMu.Unlock() } }, func(a, b string) bool { return a == b }), @@ -705,25 +707,43 @@ var runtimeSettingsFields = []fieldSpec{ xlog.Warn("invalid chats_loop_min_interval in runtime settings", "value", *s.ChatsLoopMinInterval) return false } + o.chatsLimitsMu.Lock() o.ChatsLoopMinInterval = d + o.chatsLimitsMu.Unlock() return true }), loopIntField("chats_loop_max_runs", MaxChatsLoopMaxRuns, func(s *RuntimeSettings) **int { return &s.ChatsLoopMaxRuns }, (*ApplicationConfig).EffectiveChatsLoopMaxRuns, - func(o *ApplicationConfig, v int) { o.ChatsLoopMaxRuns = v }), + func(o *ApplicationConfig, v int) { + o.chatsLimitsMu.Lock() + o.ChatsLoopMaxRuns = v + o.chatsLimitsMu.Unlock() + }), loopIntField("chats_loop_max_per_chat", MaxChatsLoopMaxPerChat, func(s *RuntimeSettings) **int { return &s.ChatsLoopMaxPerChat }, (*ApplicationConfig).EffectiveChatsLoopMaxPerChat, - func(o *ApplicationConfig, v int) { o.ChatsLoopMaxPerChat = v }), + func(o *ApplicationConfig, v int) { + o.chatsLimitsMu.Lock() + o.ChatsLoopMaxPerChat = v + o.chatsLimitsMu.Unlock() + }), loopIntField("chats_loop_max_per_user", MaxChatsLoopMaxPerUser, func(s *RuntimeSettings) **int { return &s.ChatsLoopMaxPerUser }, (*ApplicationConfig).EffectiveChatsLoopMaxPerUser, - func(o *ApplicationConfig, v int) { o.ChatsLoopMaxPerUser = v }), + func(o *ApplicationConfig, v int) { + o.chatsLimitsMu.Lock() + o.ChatsLoopMaxPerUser = v + o.chatsLimitsMu.Unlock() + }), loopIntField("chats_loop_daily_runs", MaxChatsLoopDailyRuns, func(s *RuntimeSettings) **int { return &s.ChatsLoopDailyRuns }, (*ApplicationConfig).EffectiveChatsLoopDailyRuns, - func(o *ApplicationConfig, v int) { o.ChatsLoopDailyRuns = v }), + func(o *ApplicationConfig, v int) { + o.chatsLimitsMu.Lock() + o.ChatsLoopDailyRuns = v + o.chatsLimitsMu.Unlock() + }), // Offers from the assistant: stored as the negation for UI clarity. Read // at each turn, so no restart.