Files
2026-07-13 13:00:08 +08:00

140 lines
4.1 KiB
Go

package config
import (
"fmt"
"sync"
"testing"
)
// TestLockUserConfigEditsSerializesRMW drives concurrent load-modify-save
// cycles through the edit lock and checks no writer's change is dropped.
// Without the lock, two editors load the same base config, each append their
// own connection, and the second save silently erases the first one's entry —
// the bot auto-session persistence vs. settings-save race this lock exists for.
func TestLockUserConfigEditsSerializesRMW(t *testing.T) {
// Point the user config at a temp home: SaveTo renders bot connections only
// for user-scope paths (project configs save incrementally without them).
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
path := UserConfigPath()
if path == "" {
t.Fatal("UserConfigPath is empty with REASONIX_HOME set")
}
const writers = 8
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
unlock := LockUserConfigEdits()
defer unlock()
cfg := LoadForEdit(path)
cfg.Bot.Connections = append(cfg.Bot.Connections, BotConnectionConfig{
ID: fmt.Sprintf("conn-%d", n),
Provider: "qq",
Enabled: true,
})
if err := cfg.SaveTo(path); err != nil {
t.Errorf("save: %v", err)
}
}(i)
}
wg.Wait()
cfg := LoadForEdit(path)
if got := len(cfg.Bot.Connections); got != writers {
t.Fatalf("connections = %d, want %d (concurrent read-modify-write dropped updates)", got, writers)
}
}
// TestConcurrentBotAndSettingsWritersKeepBothFields reproduces the reviewed
// P1 scenario: a bot auto-session mapping writer and a settings writer race
// on the user config. Both hold LockUserConfigEdits around their
// load-modify-save cycle, so neither may ever overwrite the other's field
// with a stale copy. Each writer also checks, under the lock, that its own
// previous round survived — any single lost update fails the test, not just
// one on the final round. Fault check: removing either writer's lock/unlock
// pair makes this test fail (at least intermittently) with "previous ...
// update lost".
func TestConcurrentBotAndSettingsWritersKeepBothFields(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
path := UserConfigPath()
if path == "" {
t.Fatal("UserConfigPath is empty with REASONIX_HOME set")
}
const rounds = 40
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
// Bot mapping writer: rewrites Bot.Connections like the botruntime
// auto-session persistence path.
go func() {
defer wg.Done()
<-start
for i := 1; i <= rounds; i++ {
unlock := LockUserConfigEdits()
cfg := LoadForEdit(path)
if i > 1 {
wantID := fmt.Sprintf("conn-%d", i-1)
if len(cfg.Bot.Connections) != 1 || cfg.Bot.Connections[0].ID != wantID {
unlock()
t.Errorf("round %d: previous bot update lost: got %+v, want single connection %q", i, cfg.Bot.Connections, wantID)
return
}
}
cfg.Bot.Connections = []BotConnectionConfig{{
ID: fmt.Sprintf("conn-%d", i),
Provider: "feishu",
Enabled: true,
}}
err := cfg.SaveTo(path)
unlock()
if err != nil {
t.Errorf("bot writer save: %v", err)
return
}
}
}()
// Settings writer: bumps an agent field like a desktop settings-page save.
go func() {
defer wg.Done()
<-start
for i := 1; i <= rounds; i++ {
unlock := LockUserConfigEdits()
cfg := LoadForEdit(path)
if i > 1 && cfg.Agent.MaxSteps != i-1 {
unlock()
t.Errorf("round %d: previous settings update lost: MaxSteps = %d, want %d", i, cfg.Agent.MaxSteps, i-1)
return
}
cfg.Agent.MaxSteps = i
err := cfg.SaveTo(path)
unlock()
if err != nil {
t.Errorf("settings writer save: %v", err)
return
}
}
}()
close(start)
wg.Wait()
if t.Failed() {
return
}
final := LoadForEdit(path)
wantID := fmt.Sprintf("conn-%d", rounds)
if len(final.Bot.Connections) != 1 || final.Bot.Connections[0].ID != wantID {
t.Fatalf("bot writer's last update lost: got %+v, want single connection %q", final.Bot.Connections, wantID)
}
if final.Agent.MaxSteps != rounds {
t.Fatalf("settings writer's last update lost: MaxSteps = %d, want %d", final.Agent.MaxSteps, rounds)
}
}