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