返回 DeepSeek-Reasonix
state_lock_test.go
根目录 / internal / pluginpkg / state_lock_test.go
1 package pluginpkg
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "runtime"
8 "sync"
9 "testing"
10 )
11
12 // TestStateConcurrentUpsertAndSetEnabled pins that concurrent load-modify-save
13 // cycles on the state file don't clobber each other: every plugin upserted by a
14 // racing goroutine must survive, with the enabled flag it was last given.
15 func TestStateConcurrentUpsertAndSetEnabled(t *testing.T) {
16 home := t.TempDir()
17 const n = 16
18
19 var wg sync.WaitGroup
20 for i := range n {
21 name := fmt.Sprintf("plugin-%02d", i)
22 wg.Go(func() {
23 if err := Upsert(home, InstalledPlugin{Name: name, Root: "plugins/" + name}); err != nil {
24 t.Errorf("Upsert(%s): %v", name, err)
25 return
26 }
27 if err := SetEnabled(home, name, true); err != nil {
28 t.Errorf("SetEnabled(%s): %v", name, err)
29 }
30 })
31 }
32 wg.Wait()
33
34 st, err := LoadState(home)
35 if err != nil {
36 t.Fatalf("LoadState: %v", err)
37 }
38 if len(st.Plugins) != n {
39 t.Fatalf("got %d plugins, want %d (lost updates)", len(st.Plugins), n)
40 }
41 for _, p := range st.Plugins {
42 if !p.Enabled {
43 t.Errorf("plugin %s lost its SetEnabled update", p.Name)
44 }
45 }
46 }
47
48 // TestStateConcurrentRemove pins that racing removals each observe their own
49 // plugin exactly once and leave nothing behind.
50 func TestStateConcurrentRemove(t *testing.T) {
51 home := t.TempDir()
52 const n = 8
53 for i := range n {
54 name := fmt.Sprintf("plugin-%02d", i)
55 if err := Upsert(home, InstalledPlugin{Name: name, Root: "plugins/" + name}); err != nil {
56 t.Fatalf("Upsert(%s): %v", name, err)
57 }
58 }
59
60 var wg sync.WaitGroup
61 for i := range n {
62 name := fmt.Sprintf("plugin-%02d", i)
63 wg.Go(func() {
64 removed, ok, err := Remove(home, name)
65 if err != nil {
66 t.Errorf("Remove(%s): %v", name, err)
67 return
68 }
69 if !ok || removed.Name != name {
70 t.Errorf("Remove(%s) = %+v, ok=%v", name, removed, ok)
71 }
72 })
73 }
74 wg.Wait()
75
76 st, err := LoadState(home)
77 if err != nil {
78 t.Fatalf("LoadState: %v", err)
79 }
80 if len(st.Plugins) != 0 {
81 t.Fatalf("got %d plugins after removing all, want 0", len(st.Plugins))
82 }
83 }
84
85 // TestStateLoadDuringSaveNeverSeesTornFile pins the atomic write: a reader
86 // racing a writer sees either the old state or the new one, never a truncated
87 // or half-written file (which would surface as a JSON parse error). On Windows
88 // the rename that publishes a new state file can make a concurrent open fail
89 // with a transient sharing violation — that is the platform's locking
90 // behavior, not a torn file, so such reads are retried instead of failed.
91 func TestStateLoadDuringSaveNeverSeesTornFile(t *testing.T) {
92 home := t.TempDir()
93 if err := Upsert(home, InstalledPlugin{Name: "seed", Root: "plugins/seed", Enabled: true}); err != nil {
94 t.Fatalf("Upsert: %v", err)
95 }
96
97 done := make(chan struct{})
98 go func() {
99 defer close(done)
100 for i := range 100 {
101 if err := SetEnabled(home, "seed", i%2 == 0); err != nil {
102 t.Errorf("SetEnabled: %v", err)
103 return
104 }
105 }
106 }()
107 // Keep the writer's lifetime inside the test body: a t.Fatalf below must
108 // not let TempDir cleanup race the still-running writer goroutine.
109 defer func() { <-done }()
110
111 for {
112 st, err := LoadState(home)
113 if err != nil {
114 var jsonErr *json.SyntaxError
115 if errors.As(err, &jsonErr) {
116 t.Fatalf("LoadState saw a torn state file: %v", err)
117 }
118 if runtime.GOOS == "windows" {
119 // Transient sharing violation while the writer renames the
120 // new state into place — retry, it is not a torn file.
121 continue
122 }
123 t.Fatalf("LoadState: %v", err)
124 }
125 if len(st.Plugins) != 1 || st.Plugins[0].Name != "seed" {
126 t.Fatalf("state = %+v, want the single seed plugin", st.Plugins)
127 }
128 select {
129 case <-done:
130 return
131 default:
132 }
133 }
134 }
135
135 lines GO