返回 DeepSeek-Reasonix
stats_test.go
根目录 / internal / plugin / stats_test.go
1 package plugin
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10 )
11
12 // withTempCache redirects config.CacheDir() at t.TempDir for the duration of a
13 // test (without it the tests write the real user cache).
14 // Returns the directory that will hold the cache subtree so callers can assert
15 // paths inside it.
16 func withTempCache(t *testing.T) string {
17 t.Helper()
18 dir := t.TempDir()
19 t.Setenv("REASONIX_CACHE_HOME", dir)
20 return dir
21 }
22
23 // readStats reads the on-disk stats file for name directly (not through the
24 // public API) so tests can assert raw file contents — what we wrote and in
25 // what order.
26 func readStats(t *testing.T, name string) StartupStats {
27 t.Helper()
28 path := statsPath(name)
29 if path == "" {
30 t.Fatal("statsPath returned empty")
31 }
32 b, err := os.ReadFile(path)
33 if err != nil {
34 t.Fatalf("read stats %s: %v", path, err)
35 }
36 var s StartupStats
37 if err := json.Unmarshal(b, &s); err != nil {
38 t.Fatalf("unmarshal stats: %v", err)
39 }
40 return s
41 }
42
43 func TestRecordStartupAppends(t *testing.T) {
44 withTempCache(t)
45
46 durs := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
47 for _, d := range durs {
48 if err := RecordStartup("foo", d); err != nil {
49 t.Fatalf("RecordStartup: %v", err)
50 }
51 }
52
53 s := readStats(t, "foo")
54 if s.Version != statsVersion {
55 t.Fatalf("Version = %d, want %d", s.Version, statsVersion)
56 }
57 if got := len(s.SamplesMs); got != 3 {
58 t.Fatalf("samples = %d, want 3", got)
59 }
60 // File is written oldest→newest (newest at the tail).
61 want := []int64{100, 200, 300}
62 for i, w := range want {
63 if s.SamplesMs[i] != w {
64 t.Fatalf("SamplesMs[%d] = %d, want %d (full=%v)", i, s.SamplesMs[i], w, s.SamplesMs)
65 }
66 }
67 if s.LastSeen.IsZero() {
68 t.Fatal("LastSeen should be set after a record")
69 }
70 }
71
72 func TestRecordStartupCapsAtWindow(t *testing.T) {
73 withTempCache(t)
74
75 const total = 25
76 for i := 0; i < total; i++ {
77 // Use distinct values so we can verify the *oldest* ones were dropped.
78 if err := RecordStartup("bar", time.Duration(i+1)*time.Millisecond); err != nil {
79 t.Fatalf("RecordStartup #%d: %v", i, err)
80 }
81 }
82
83 s := readStats(t, "bar")
84 if got := len(s.SamplesMs); got != maxSamples {
85 t.Fatalf("samples = %d, want %d", got, maxSamples)
86 }
87 // First retained sample should be #6 (1..5 dropped); last should be #25.
88 wantFirst := int64(total - maxSamples + 1) // 6
89 if s.SamplesMs[0] != wantFirst {
90 t.Fatalf("SamplesMs[0] = %d, want %d", s.SamplesMs[0], wantFirst)
91 }
92 if s.SamplesMs[len(s.SamplesMs)-1] != int64(total) {
93 t.Fatalf("SamplesMs[last] = %d, want %d", s.SamplesMs[len(s.SamplesMs)-1], total)
94 }
95 }
96
97 func TestRecommendBelowBudgetNoDemote(t *testing.T) {
98 withTempCache(t)
99
100 budget := 1 * time.Second
101 // All samples comfortably under budget*2 == 2s.
102 for _, ms := range []int64{500, 800, 1200, 1500, 900} {
103 if err := RecordStartup("ok-plugin", time.Duration(ms)*time.Millisecond); err != nil {
104 t.Fatalf("RecordStartup: %v", err)
105 }
106 }
107
108 got := Recommend("ok-plugin", budget, 3)
109 if got.Demote {
110 t.Fatalf("Demote = true, want false (reason=%q)", got.Reason)
111 }
112 }
113
114 func TestRecommendAboveBudgetDemotes(t *testing.T) {
115 withTempCache(t)
116
117 budget := 1 * time.Second
118 // First sample is fast (would block a naive "ever-slow" check), then 3
119 // consecutive samples above budget*2 == 2s. The plan says demote on
120 // "last N consecutive over-budget", so this should trip.
121 for _, ms := range []int64{500, 2500, 3000, 4000} {
122 if err := RecordStartup("slow-plugin", time.Duration(ms)*time.Millisecond); err != nil {
123 t.Fatalf("RecordStartup: %v", err)
124 }
125 }
126
127 got := Recommend("slow-plugin", budget, 3)
128 if !got.Demote {
129 t.Fatalf("Demote = false, want true (samples=%+v)", readStats(t, "slow-plugin").SamplesMs)
130 }
131 if got.Reason == "" {
132 t.Fatal("Reason should be non-empty when demoting")
133 }
134 if got.P99 <= 0 {
135 t.Fatalf("P99 = %v, want > 0", got.P99)
136 }
137 }
138
139 func TestRecommendAtBudgetDemotes(t *testing.T) {
140 withTempCache(t)
141
142 budget := 5 * time.Second
143 for i := 0; i < 3; i++ {
144 if err := RecordStartup("timeout-plugin", budget); err != nil {
145 t.Fatalf("RecordStartup #%d: %v", i, err)
146 }
147 }
148
149 got := Recommend("timeout-plugin", budget, 3)
150 if !got.Demote {
151 t.Fatalf("Demote = false, want true for repeated budget hits (samples=%+v)", readStats(t, "timeout-plugin").SamplesMs)
152 }
153 }
154
155 func TestRecommendMissingStatsNoDemote(t *testing.T) {
156 withTempCache(t)
157
158 // Never recorded anything → should not demote a brand-new plugin.
159 got := Recommend("never-seen", 1*time.Second, 3)
160 if got.Demote {
161 t.Fatalf("Demote = true on missing stats, want false (reason=%q)", got.Reason)
162 }
163 if got.P99 != 0 {
164 t.Fatalf("P99 = %v on missing stats, want 0", got.P99)
165 }
166 }
167
168 func TestRecommendOldFailuresFadeOut(t *testing.T) {
169 withTempCache(t)
170
171 budget := 1 * time.Second
172 // Early samples were terrible (well above budget*2 == 2s), but the most
173 // recent three are quick — rolling window must let the plugin recover.
174 for _, ms := range []int64{5000, 6000, 7000, 8000, 500, 600, 700} {
175 if err := RecordStartup("recovered", time.Duration(ms)*time.Millisecond); err != nil {
176 t.Fatalf("RecordStartup: %v", err)
177 }
178 }
179
180 got := Recommend("recovered", budget, 3)
181 if got.Demote {
182 t.Fatalf("Demote = true after recovery, want false (samples=%+v, reason=%q)",
183 readStats(t, "recovered").SamplesMs, got.Reason)
184 }
185 }
186
187 // TestStatsPathLayout pins the on-disk location so Phase 4's integration code
188 // can rely on it. We assert the path sits under config.CacheDir()/mcp and that
189 // the slug strips raw separators — exact slug rule lives in cache.go.
190 func TestStatsPathLayout(t *testing.T) {
191 root := withTempCache(t)
192
193 p := statsPath("server/with spaces")
194 if p == "" {
195 t.Fatal("statsPath returned empty")
196 }
197 wantParent := filepath.Join(root, "mcp")
198 if got := filepath.Dir(p); got != wantParent {
199 t.Fatalf("parent = %q, want %q", got, wantParent)
200 }
201 base := filepath.Base(p)
202 if filepath.Ext(base) != ".json" {
203 t.Fatalf("ext = %q, want .json", filepath.Ext(base))
204 }
205 if !strings.HasSuffix(base, ".stats.json") {
206 t.Fatalf("base = %q, want .stats.json suffix", base)
207 }
208 if containsAny(base, "/ ") {
209 t.Fatalf("slug %q contains raw separators", base)
210 }
211 }
212
213 func containsAny(s, chars string) bool {
214 for _, c := range chars {
215 for _, r := range s {
216 if r == c {
217 return true
218 }
219 }
220 }
221 return false
222 }
223
224 // TestStatsSlugCollisionDoesNotCrossDemote: "foo.bar" and "foo-bar" collapse
225 // to one slug. Each server now gets its own hash-suffixed file, so a slow
226 // server's samples must not demote the other, and interleaved startups must
227 // not reset each other's windows (the old shared-file ownership check made
228 // each writer wipe the other's history, so neither could ever accumulate
229 // enough consecutive samples to demote).
230 func TestStatsSlugCollisionDoesNotCrossDemote(t *testing.T) {
231 withTempCache(t)
232
233 // Interleave a chronically slow server with a healthy one sharing the slug.
234 for i := 0; i < defaultDemoteAfter; i++ {
235 if err := RecordStartup("foo.bar", 5*time.Second); err != nil {
236 t.Fatalf("RecordStartup foo.bar: %v", err)
237 }
238 if err := RecordStartup("foo-bar", 10*time.Millisecond); err != nil {
239 t.Fatalf("RecordStartup foo-bar: %v", err)
240 }
241 }
242 // Both windows must have accumulated independently despite interleaving.
243 slow := readStats(t, "foo.bar")
244 fast := readStats(t, "foo-bar")
245 if len(slow.SamplesMs) != defaultDemoteAfter || slow.Name != "foo.bar" {
246 t.Fatalf("foo.bar window = %+v, want %d samples owned by foo.bar", slow, defaultDemoteAfter)
247 }
248 if len(fast.SamplesMs) != defaultDemoteAfter || fast.Name != "foo-bar" {
249 t.Fatalf("foo-bar window = %+v, want %d samples owned by foo-bar", fast, defaultDemoteAfter)
250 }
251 // The slow server demotes on its own history; the healthy one does not.
252 if rec := Recommend("foo.bar", time.Second, 0); !rec.Demote {
253 t.Fatalf("foo.bar should demote on its own history: %+v", rec)
254 }
255 if rec := Recommend("foo-bar", time.Second, 0); rec.Demote {
256 t.Fatalf("foo-bar demoted off foo.bar's samples: %+v", rec)
257 }
258 }
259
260 // TestStatsLegacyNamedFileMigrated: a pre-hash stats file whose recorded Name
261 // matches is this server's own history — Recommend keeps working from it and
262 // the next write carries it into the hashed path.
263 func TestStatsLegacyNamedFileMigrated(t *testing.T) {
264 withTempCache(t)
265
266 legacy := StartupStats{Version: statsVersion, Name: "legacy", LastSeen: time.Now()}
267 for i := 0; i < 3; i++ {
268 legacy.SamplesMs = append(legacy.SamplesMs, 100)
269 }
270 if err := writeStatsAtomic(legacyStatsPath("legacy"), legacy); err != nil {
271 t.Fatalf("write legacy stats: %v", err)
272 }
273
274 if rec := Recommend("legacy", time.Second, 0); rec.P99 == 0 {
275 t.Fatalf("legacy named stats not readable: %+v", rec)
276 }
277 if err := RecordStartup("legacy", 100*time.Millisecond); err != nil {
278 t.Fatalf("RecordStartup after legacy: %v", err)
279 }
280 s := readStats(t, "legacy")
281 if s.Name != "legacy" || len(s.SamplesMs) != 4 {
282 t.Fatalf("legacy migration = %+v, want name kept and 4 samples", s)
283 }
284 }
285
286 // TestStatsLegacyNamelessCollisionNotTrusted: a pre-ownership legacy file has
287 // no Name, so it is shared by every server whose name collapses to the slug
288 // and its samples cannot be attributed. It must not demote anyone and must
289 // not seed a new window.
290 func TestStatsLegacyNamelessCollisionNotTrusted(t *testing.T) {
291 withTempCache(t)
292
293 nameless := StartupStats{Version: statsVersion, LastSeen: time.Now()}
294 for i := 0; i < defaultDemoteAfter; i++ {
295 nameless.SamplesMs = append(nameless.SamplesMs, 5000) // over budget
296 }
297 if err := writeStatsAtomic(legacyStatsPath("foo.bar"), nameless); err != nil {
298 t.Fatalf("write nameless legacy stats: %v", err)
299 }
300
301 // Neither colliding server may be demoted off unattributable samples.
302 if rec := Recommend("foo.bar", time.Second, 0); rec.Demote {
303 t.Fatalf("foo.bar demoted off nameless legacy samples: %+v", rec)
304 }
305 if rec := Recommend("foo-bar", time.Second, 0); rec.Demote {
306 t.Fatalf("foo-bar demoted off nameless legacy samples: %+v", rec)
307 }
308 // A new write starts a fresh window instead of inheriting the samples.
309 if err := RecordStartup("foo.bar", 10*time.Millisecond); err != nil {
310 t.Fatalf("RecordStartup: %v", err)
311 }
312 s := readStats(t, "foo.bar")
313 if s.Name != "foo.bar" || len(s.SamplesMs) != 1 {
314 t.Fatalf("window after nameless legacy = %+v, want fresh single-sample window", s)
315 }
316 }
317
318 // TestStatsLegacyFileWithoutNameAdopted: a nameless file at the hashed path is
319 // unambiguous — the hash pins it to exactly one server name — so its samples
320 // stay usable and the next write adopts it without dropping history. (Nameless
321 // files at the shared legacy path are the untrusted case, covered above.)
322 func TestStatsLegacyFileWithoutNameAdopted(t *testing.T) {
323 withTempCache(t)
324
325 for i := 0; i < 3; i++ {
326 if err := RecordStartup("legacy", 100*time.Millisecond); err != nil {
327 t.Fatalf("RecordStartup: %v", err)
328 }
329 }
330 // Simulate a legacy file: strip the Name field.
331 path := statsPath("legacy")
332 s := readStats(t, "legacy")
333 s.Name = ""
334 if err := writeStatsAtomic(path, s); err != nil {
335 t.Fatalf("write legacy stats: %v", err)
336 }
337
338 // Recommend still reads the legacy file (no ownership check possible).
339 if rec := Recommend("legacy", time.Second, 0); rec.P99 == 0 {
340 t.Fatalf("legacy stats not readable: %+v", rec)
341 }
342 // The next write adopts the file without dropping history.
343 if err := RecordStartup("legacy", 100*time.Millisecond); err != nil {
344 t.Fatalf("RecordStartup after legacy: %v", err)
345 }
346 s = readStats(t, "legacy")
347 if s.Name != "legacy" || len(s.SamplesMs) != 4 {
348 t.Fatalf("legacy adoption = %+v, want name set and 4 samples kept", s)
349 }
350 }
351
352 // TestStatsPathBoundedForLongNames: the hash suffix added for collision
353 // safety must not push a previously-writable long slug past the 255-byte
354 // filename component limit (slugs of 236-244 bytes could write
355 // "<slug>.stats.json" before, but "<slug>-<hash>.stats.json" would exceed it).
356 func TestStatsPathBoundedForLongNames(t *testing.T) {
357 withTempCache(t)
358
359 long := strings.Repeat("a", 240)
360 p := statsPath(long)
361 if p == "" {
362 t.Fatal("statsPath returned empty")
363 }
364 if base := filepath.Base(p); len(base) > 255 {
365 t.Fatalf("component = %d bytes, exceeds 255 limit: %q", len(base), base)
366 }
367 // The bounded path must still be writable and readable end-to-end.
368 if err := RecordStartup(long, 100*time.Millisecond); err != nil {
369 t.Fatalf("RecordStartup long name: %v", err)
370 }
371 s := readStats(t, long)
372 if s.Name != long || len(s.SamplesMs) != 1 {
373 t.Fatalf("long-name stats = %+v, want one owned sample", s)
374 }
375 // Distinct long names sharing the truncated stem must keep distinct files.
376 other := strings.Repeat("a", 240) + "b"
377 if statsPath(other) == p {
378 t.Fatalf("distinct long names collapsed to one stats path: %q", p)
379 }
380 }
381
381 lines GO