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
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 21:50:57 +00:00
1 parent 352b7113d0
commit 06adb4b94e
3 files changed
+104 -6

No files matched your search

+27
View File
@@ -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
}
+51
View File
@@ -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))
})
})
+26 -6
View File
@@ -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.