返回 DeepSeek-Reasonix
mutate_test.go
根目录 / internal / config / mutate_test.go
1 package config
2
3 import (
4 "bytes"
5 "context"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15 )
16
17 // TestLockUserConfigEditsSerializesRMW drives concurrent load-modify-save
18 // cycles through the edit lock and checks no writer's change is dropped.
19 // Without the lock, two editors load the same base config, each append their
20 // own connection, and the second save silently erases the first one's entry —
21 // the bot auto-session persistence vs. settings-save race this lock exists for.
22 func TestLockUserConfigEditsSerializesRMW(t *testing.T) {
23 // Point the user config at a temp home: SaveTo renders bot connections only
24 // for user-scope paths (project configs save incrementally without them).
25 home := t.TempDir()
26 t.Setenv("REASONIX_HOME", home)
27 path := UserConfigPath()
28 if path == "" {
29 t.Fatal("UserConfigPath is empty with REASONIX_HOME set")
30 }
31
32 const writers = 8
33 var wg sync.WaitGroup
34 for i := 0; i < writers; i++ {
35 wg.Add(1)
36 go func(n int) {
37 defer wg.Done()
38 unlock := LockUserConfigEdits()
39 defer unlock()
40 cfg := LoadForEdit(path)
41 cfg.Bot.Connections = append(cfg.Bot.Connections, BotConnectionConfig{
42 ID: fmt.Sprintf("conn-%d", n),
43 Provider: "qq",
44 Enabled: true,
45 })
46 if err := cfg.SaveTo(path); err != nil {
47 t.Errorf("save: %v", err)
48 }
49 }(i)
50 }
51 wg.Wait()
52
53 cfg := LoadForEdit(path)
54 if got := len(cfg.Bot.Connections); got != writers {
55 t.Fatalf("connections = %d, want %d (concurrent read-modify-write dropped updates)", got, writers)
56 }
57 }
58
59 // TestConcurrentBotAndSettingsWritersKeepBothFields reproduces the reviewed
60 // P1 scenario: a bot auto-session mapping writer and a settings writer race
61 // on the user config. Both hold LockUserConfigEdits around their
62 // load-modify-save cycle, so neither may ever overwrite the other's field
63 // with a stale copy. Each writer also checks, under the lock, that its own
64 // previous round survived — any single lost update fails the test, not just
65 // one on the final round. Fault check: removing either writer's lock/unlock
66 // pair makes this test fail (at least intermittently) with "previous ...
67 // update lost".
68 func TestConcurrentBotAndSettingsWritersKeepBothFields(t *testing.T) {
69 home := t.TempDir()
70 t.Setenv("REASONIX_HOME", home)
71 path := UserConfigPath()
72 if path == "" {
73 t.Fatal("UserConfigPath is empty with REASONIX_HOME set")
74 }
75
76 const rounds = 40
77 start := make(chan struct{})
78 var wg sync.WaitGroup
79 wg.Add(2)
80
81 // Bot mapping writer: rewrites Bot.Connections like the botruntime
82 // auto-session persistence path.
83 go func() {
84 defer wg.Done()
85 <-start
86 for i := 1; i <= rounds; i++ {
87 unlock := LockUserConfigEdits()
88 cfg := LoadForEdit(path)
89 if i > 1 {
90 wantID := fmt.Sprintf("conn-%d", i-1)
91 if len(cfg.Bot.Connections) != 1 || cfg.Bot.Connections[0].ID != wantID {
92 unlock()
93 t.Errorf("round %d: previous bot update lost: got %+v, want single connection %q", i, cfg.Bot.Connections, wantID)
94 return
95 }
96 }
97 cfg.Bot.Connections = []BotConnectionConfig{{
98 ID: fmt.Sprintf("conn-%d", i),
99 Provider: "feishu",
100 Enabled: true,
101 }}
102 err := cfg.SaveTo(path)
103 unlock()
104 if err != nil {
105 t.Errorf("bot writer save: %v", err)
106 return
107 }
108 }
109 }()
110
111 // Settings writer: bumps a supported agent field like a desktop settings-page save.
112 go func() {
113 defer wg.Done()
114 <-start
115 for i := 1; i <= rounds; i++ {
116 unlock := LockUserConfigEdits()
117 cfg := LoadForEdit(path)
118 if i > 1 && cfg.Agent.Temperature != float64(i-1) {
119 unlock()
120 t.Errorf("round %d: previous settings update lost: Temperature = %v, want %d", i, cfg.Agent.Temperature, i-1)
121 return
122 }
123 cfg.Agent.Temperature = float64(i)
124 err := cfg.SaveTo(path)
125 unlock()
126 if err != nil {
127 t.Errorf("settings writer save: %v", err)
128 return
129 }
130 }
131 }()
132
133 close(start)
134 wg.Wait()
135 if t.Failed() {
136 return
137 }
138
139 final := LoadForEdit(path)
140 wantID := fmt.Sprintf("conn-%d", rounds)
141 if len(final.Bot.Connections) != 1 || final.Bot.Connections[0].ID != wantID {
142 t.Fatalf("bot writer's last update lost: got %+v, want single connection %q", final.Bot.Connections, wantID)
143 }
144 if final.Agent.Temperature != rounds {
145 t.Fatalf("settings writer's last update lost: Temperature = %v, want %d", final.Agent.Temperature, rounds)
146 }
147 }
148
149 func TestLockUserConfigEditsSerializesAcrossProcessesWithDifferentTempDirs(t *testing.T) {
150 home := t.TempDir()
151 assertUserConfigLockSerializesAcrossProcesses(
152 t,
153 home,
154 home,
155 filepath.Join(t.TempDir(), "tmp-a"),
156 filepath.Join(t.TempDir(), "tmp-b"),
157 )
158 }
159
160 func TestLockUserConfigEditsSerializesDarwinCaseAliasesAcrossProcesses(t *testing.T) {
161 if runtime.GOOS != "darwin" {
162 t.Skip("Darwin path aliases only")
163 }
164 parent := t.TempDir()
165 home := filepath.Join(parent, "MiXeDHome")
166 if err := os.MkdirAll(home, 0o700); err != nil {
167 t.Fatal(err)
168 }
169 alias := strings.ToUpper(home)
170 homeInfo, homeErr := os.Stat(home)
171 aliasInfo, aliasErr := os.Stat(alias)
172 if homeErr != nil || aliasErr != nil || !os.SameFile(homeInfo, aliasInfo) {
173 t.Skip("test volume is case-sensitive")
174 }
175 assertUserConfigLockSerializesAcrossProcesses(t, home, alias, t.TempDir(), t.TempDir())
176 }
177
178 func assertUserConfigLockSerializesAcrossProcesses(t *testing.T, firstHome, secondHome, firstTmp, secondTmp string) {
179 t.Helper()
180 if err := os.MkdirAll(firstTmp, 0o700); err != nil {
181 t.Fatal(err)
182 }
183 if err := os.MkdirAll(secondTmp, 0o700); err != nil {
184 t.Fatal(err)
185 }
186 home := firstHome
187 t.Setenv("REASONIX_HOME", home)
188 path := UserConfigPath()
189 if err := Default().SaveTo(path); err != nil {
190 t.Fatal(err)
191 }
192
193 signals := t.TempDir()
194 aStarted := filepath.Join(signals, "a-started")
195 aAcquired := filepath.Join(signals, "a-acquired")
196 aRelease := filepath.Join(signals, "a-release")
197 bStarted := filepath.Join(signals, "b-started")
198 bAcquired := filepath.Join(signals, "b-acquired")
199
200 startHelper := func(mode, processHome, processTmp, started, acquired, release string) (*exec.Cmd, *bytes.Buffer) {
201 t.Helper()
202 cmd := exec.Command(os.Args[0], "-test.run=^TestLockUserConfigEditsHelperProcess$")
203 cmd.Env = testEnvWithOverrides(map[string]string{
204 "TMPDIR": processTmp,
205 "REASONIX_HOME": processHome,
206 "REASONIX_CONFIG_LOCK_HELPER": "1",
207 "REASONIX_CONFIG_LOCK_MODE": mode,
208 "REASONIX_CONFIG_LOCK_STARTED": started,
209 "REASONIX_CONFIG_LOCK_ACQUIRED": acquired,
210 "REASONIX_CONFIG_LOCK_RELEASE": release,
211 })
212 var output bytes.Buffer
213 cmd.Stdout = &output
214 cmd.Stderr = &output
215 if err := cmd.Start(); err != nil {
216 t.Fatalf("start %s helper: %v", mode, err)
217 }
218 return cmd, &output
219 }
220 waitForFile := func(path string) {
221 t.Helper()
222 deadline := time.Now().Add(5 * time.Second)
223 for time.Now().Before(deadline) {
224 if _, err := os.Stat(path); err == nil {
225 return
226 }
227 time.Sleep(10 * time.Millisecond)
228 }
229 t.Fatalf("timed out waiting for %s", path)
230 }
231
232 first, firstOutput := startHelper("bot", firstHome, firstTmp, aStarted, aAcquired, aRelease)
233 waitForFile(aAcquired)
234 second, secondOutput := startHelper("cli", secondHome, secondTmp, bStarted, bAcquired, "")
235 waitForFile(bStarted)
236 time.Sleep(150 * time.Millisecond)
237 if _, err := os.Stat(bAcquired); err == nil {
238 firstLock, _ := os.ReadFile(aAcquired)
239 secondLock, _ := os.ReadFile(bAcquired)
240 t.Fatalf(
241 "second process acquired the user config lock before the first released it: first=%q second=%q",
242 strings.TrimSpace(string(firstLock)),
243 strings.TrimSpace(string(secondLock)),
244 )
245 }
246 if err := os.WriteFile(aRelease, []byte("release\n"), 0o600); err != nil {
247 t.Fatal(err)
248 }
249 if err := first.Wait(); err != nil {
250 t.Fatalf("first helper: %v\n%s", err, firstOutput.String())
251 }
252 if err := second.Wait(); err != nil {
253 t.Fatalf("second helper: %v\n%s", err, secondOutput.String())
254 }
255
256 final, err := LoadForEditReadOnlyStrict(path)
257 if err != nil {
258 t.Fatal(err)
259 }
260 if len(final.Bot.Connections) != 1 || final.Bot.Connections[0].ID != "cross-process" {
261 t.Fatalf("bot update was lost: %+v", final.Bot.Connections)
262 }
263 if got := final.CLIUpdateChannel(); got != "stable" {
264 t.Fatalf("CLI channel migration was lost: %q", got)
265 }
266 }
267
268 func testEnvWithOverrides(overrides map[string]string) []string {
269 env := make([]string, 0, len(os.Environ())+len(overrides))
270 for _, entry := range os.Environ() {
271 key, _, ok := strings.Cut(entry, "=")
272 if ok {
273 if _, overridden := overrides[key]; overridden {
274 continue
275 }
276 }
277 env = append(env, entry)
278 }
279 for key, value := range overrides {
280 env = append(env, key+"="+value)
281 }
282 return env
283 }
284
285 func TestLockUserConfigEditsFailsClosedWhenFileLockTimesOut(t *testing.T) {
286 home := t.TempDir()
287 t.Setenv("REASONIX_HOME", home)
288 path := UserConfigPath()
289 if err := Default().SaveTo(path); err != nil {
290 t.Fatal(err)
291 }
292
293 release, err := acquireConfigFileEditLockWithTimeout(path, time.Second)
294 if err != nil {
295 t.Fatalf("hold config file lock: %v", err)
296 }
297 defer release()
298
299 previousTimeout := userConfigEditLockTimeout
300 userConfigEditLockTimeout = 30 * time.Millisecond
301 t.Cleanup(func() { userConfigEditLockTimeout = previousTimeout })
302
303 unlock := LockUserConfigEdits()
304 defer unlock()
305 if err := currentUserConfigEditLockError(); err == nil {
306 t.Fatal("LockUserConfigEdits did not report the file-lock timeout")
307 }
308
309 cfg := LoadForEdit(path)
310 if err := cfg.SetCLIUpdateChannel("preview"); err != nil {
311 t.Fatal(err)
312 }
313 if err := cfg.SaveTo(path); err == nil {
314 t.Fatal("SaveTo wrote user config after the cross-process lock failed")
315 }
316 }
317
318 func TestLockUserConfigEditsHelperProcess(t *testing.T) {
319 if os.Getenv("REASONIX_CONFIG_LOCK_HELPER") != "1" {
320 return
321 }
322 started := os.Getenv("REASONIX_CONFIG_LOCK_STARTED")
323 acquired := os.Getenv("REASONIX_CONFIG_LOCK_ACQUIRED")
324 release := os.Getenv("REASONIX_CONFIG_LOCK_RELEASE")
325 if err := os.WriteFile(started, []byte("started\n"), 0o600); err != nil {
326 t.Fatal(err)
327 }
328
329 unlock := LockUserConfigEdits()
330 defer unlock()
331 if err := currentUserConfigEditLockError(); err != nil {
332 t.Fatalf("acquire user config file lock: %v", err)
333 }
334 lockPath, err := configFileEditLockPath(UserConfigPath())
335 if err != nil {
336 t.Fatal(err)
337 }
338 if err := os.WriteFile(acquired, []byte(lockPath+"\n"), 0o600); err != nil {
339 t.Fatal(err)
340 }
341 if release != "" {
342 deadline := time.Now().Add(5 * time.Second)
343 for {
344 if _, err := os.Stat(release); err == nil {
345 break
346 }
347 if time.Now().After(deadline) {
348 t.Fatalf("timed out waiting for release signal")
349 }
350 time.Sleep(10 * time.Millisecond)
351 }
352 }
353
354 path := UserConfigPath()
355 cfg, err := LoadForEditReadOnlyStrict(path)
356 if err != nil {
357 t.Fatal(err)
358 }
359 switch os.Getenv("REASONIX_CONFIG_LOCK_MODE") {
360 case "bot":
361 cfg.Bot.Connections = []BotConnectionConfig{{
362 ID: "cross-process",
363 Provider: "qq",
364 Enabled: true,
365 }}
366 case "cli":
367 if err := cfg.SetCLIUpdateChannel("preview"); err != nil {
368 t.Fatal(err)
369 }
370 default:
371 t.Fatal("unknown helper mode")
372 }
373 if err := cfg.SaveTo(path); err != nil {
374 t.Fatal(err)
375 }
376 }
377
378 func TestConfigEditLockCanonicalizesAliasesAndIgnoresCacheOverrides(t *testing.T) {
379 dir := t.TempDir()
380
381 target := filepath.Join(dir, "target.toml")
382 link := filepath.Join(dir, "reasonix.toml")
383 if err := os.WriteFile(target, []byte("[agent]\ntemperature = 0.1\n"), 0o644); err != nil {
384 t.Fatal(err)
385 }
386 if err := os.Symlink(target, link); err != nil {
387 t.Skipf("symlinks are unavailable: %v", err)
388 }
389
390 aliasLock, err := configFileEditLockPath(link)
391 if err != nil {
392 t.Fatal(err)
393 }
394 targetLock, err := configFileEditLockPath(target)
395 if err != nil {
396 t.Fatal(err)
397 }
398 if aliasLock != targetLock {
399 t.Fatalf("alias lock = %q, target lock = %q", aliasLock, targetLock)
400 }
401
402 t.Setenv("REASONIX_CACHE_HOME", filepath.Join(dir, "cache-a"))
403 first, err := configFileEditLockPath(link)
404 if err != nil {
405 t.Fatal(err)
406 }
407 t.Setenv("REASONIX_CACHE_HOME", filepath.Join(dir, "cache-b"))
408 second, err := configFileEditLockPath(link)
409 if err != nil {
410 t.Fatal(err)
411 }
412 if first != second {
413 t.Fatalf("cache override split config lock: %q != %q", first, second)
414 }
415 t.Setenv("HOME", filepath.Join(dir, "isolated-home"))
416 t.Setenv("REASONIX_HOME", filepath.Join(dir, "reasonix-home"))
417 t.Setenv("TMPDIR", filepath.Join(dir, "tmp-a"))
418 third, err := configFileEditLockPath(link)
419 if err != nil {
420 t.Fatal(err)
421 }
422 if first != third {
423 t.Fatalf("HOME/profile/TMPDIR override split config lock: %q != %q", first, third)
424 }
425 wantDir, err := configEditLockRegistryDir()
426 if err != nil {
427 t.Fatal(err)
428 }
429 if filepath.Dir(first) != wantDir {
430 t.Fatalf("lock dir = %q, want OS-user registry %q", filepath.Dir(first), wantDir)
431 }
432 }
433
434 func TestAcquireConfigEditLockRejectsSymlinkRegistry(t *testing.T) {
435 dir := t.TempDir()
436 realDir := filepath.Join(dir, "real")
437 if err := os.Mkdir(realDir, 0o700); err != nil {
438 t.Fatal(err)
439 }
440 linkDir := filepath.Join(dir, "locks")
441 if err := os.Symlink(realDir, linkDir); err != nil {
442 t.Skipf("symlinks are unavailable: %v", err)
443 }
444 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
445 defer cancel()
446 if unlock, err := acquireConfigEditLockPath(ctx, filepath.Join(linkDir, "config.lock")); err == nil {
447 unlock()
448 t.Fatal("symlinked lock registry was accepted")
449 }
450 }
451
452 func TestAcquireConfigEditLockSecuresRegistryPermissions(t *testing.T) {
453 if runtime.GOOS == "windows" {
454 t.Skip("Windows protects the per-user lock registry through inherited ACLs, not Unix permission bits")
455 }
456 dir := filepath.Join(t.TempDir(), "locks")
457 if err := os.Mkdir(dir, 0o755); err != nil {
458 t.Fatal(err)
459 }
460 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
461 defer cancel()
462 unlock, err := acquireConfigEditLockPath(ctx, filepath.Join(dir, "config.lock"))
463 if err != nil {
464 t.Fatal(err)
465 }
466 defer unlock()
467 info, err := os.Stat(dir)
468 if err != nil {
469 t.Fatal(err)
470 }
471 if got := info.Mode().Perm(); got != 0o700 {
472 t.Fatalf("lock registry mode = %04o, want 0700", got)
473 }
474 }
475
476 func TestConfigEditTransactionPinsSymlinkTarget(t *testing.T) {
477 dir := t.TempDir()
478
479 first := filepath.Join(dir, "first.toml")
480 second := filepath.Join(dir, "second.toml")
481 link := filepath.Join(dir, "reasonix.toml")
482 if err := os.WriteFile(first, []byte("[agent]\ntemperature = 0.1\n"), 0o644); err != nil {
483 t.Fatal(err)
484 }
485 const secondBody = "[agent]\ntemperature = 0.9\n"
486 if err := os.WriteFile(second, []byte(secondBody), 0o644); err != nil {
487 t.Fatal(err)
488 }
489 if err := os.Symlink(first, link); err != nil {
490 t.Skipf("symlinks are unavailable: %v", err)
491 }
492
493 unlock, err := LockConfigFileEdits(link)
494 if err != nil {
495 t.Fatal(err)
496 }
497 defer unlock()
498 if err := os.Remove(link); err != nil {
499 t.Fatal(err)
500 }
501 if err := os.Symlink(second, link); err != nil {
502 t.Fatal(err)
503 }
504
505 cfg, err := LoadForEditReadOnlyStrict(link)
506 if err != nil {
507 t.Fatal(err)
508 }
509 if cfg.Agent.Temperature != 0.1 {
510 t.Fatalf("transaction followed retargeted link: temperature = %v, want 0.1", cfg.Agent.Temperature)
511 }
512 cfg.Agent.Temperature = 0.2
513 if err := cfg.SaveTo(link); err != nil {
514 t.Fatal(err)
515 }
516
517 gotFirst, err := os.ReadFile(first)
518 if err != nil {
519 t.Fatal(err)
520 }
521 if !strings.Contains(string(gotFirst), "temperature = 0.2") {
522 t.Fatalf("pinned target was not updated:\n%s", gotFirst)
523 }
524 gotSecond, err := os.ReadFile(second)
525 if err != nil {
526 t.Fatal(err)
527 }
528 if string(gotSecond) != secondBody {
529 t.Fatalf("retargeted destination was modified: %q", gotSecond)
530 }
531 }
532
533 func TestLoadForEditMalformedConfigCannotBeSaved(t *testing.T) {
534 path := filepath.Join(t.TempDir(), "reasonix.toml")
535 const malformed = "[agent\ntemperature = 0.4\n"
536 if err := os.WriteFile(path, []byte(malformed), 0o644); err != nil {
537 t.Fatal(err)
538 }
539
540 cfg := LoadForEdit(path)
541 cfg.Agent.Temperature = 0.7
542 if err := cfg.SaveTo(path); err == nil {
543 t.Fatal("SaveTo accepted defaults returned after a malformed edit load")
544 }
545 got, err := os.ReadFile(path)
546 if err != nil {
547 t.Fatal(err)
548 }
549 if string(got) != malformed {
550 t.Fatalf("malformed config was overwritten: %q", got)
551 }
552 }
553
553 lines GO