| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "runtime" |
| 12 | "strings" |
| 13 | "sync" |
| 14 | "sync/atomic" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "reasonix/internal/filelock" |
| 19 | ) |
| 20 | |
| 21 | func warningFingerprint(label string) string { |
| 22 | digest := sha256.Sum256([]byte(label)) |
| 23 | return hex.EncodeToString(digest[:]) |
| 24 | } |
| 25 | |
| 26 | func missingReasoningTestNow() time.Time { |
| 27 | return time.Now().Add(-time.Hour).Truncate(time.Millisecond) |
| 28 | } |
| 29 | |
| 30 | func TestMissingReasoningWarnStatePersistsCurrentIncidentAcrossInstances(t *testing.T) { |
| 31 | dir := t.TempDir() |
| 32 | fingerprint := warningFingerprint("openai\x00deepseek\x00v4-pro") |
| 33 | observedAt := missingReasoningTestNow() |
| 34 | if !newMissingReasoningWarnState(dir).claimAt(fingerprint, observedAt) { |
| 35 | t.Fatal("fresh configuration must claim its first incident notice") |
| 36 | } |
| 37 | if newMissingReasoningWarnState(dir).claimAt(fingerprint, observedAt.Add(time.Minute)) { |
| 38 | t.Fatal("fresh instance must suppress the same current incident") |
| 39 | } |
| 40 | |
| 41 | b, err := os.ReadFile(filepath.Join(dir, missingReasoningWarnStateFilename)) |
| 42 | if err != nil { |
| 43 | t.Fatalf("state file missing after claim: %v", err) |
| 44 | } |
| 45 | latestObservedAt := observedAt.Add(time.Minute) |
| 46 | want := fmt.Sprintf(`{"version":2,"incidents":[{"fingerprint":"%s","warnedAtUnixMs":%d,"lastMissingAtUnixMs":%d,"lastMissingAtUnixNano":%d}]}`, |
| 47 | fingerprint, observedAt.UnixMilli(), latestObservedAt.UnixMilli(), latestObservedAt.UnixNano()) |
| 48 | if got := string(b); got != want { |
| 49 | t.Fatalf("state file = %s, want %s", got, want) |
| 50 | } |
| 51 | if strings.Contains(string(b), "deepseek") || strings.Contains(string(b), "v4-pro") { |
| 52 | t.Fatalf("state file exposed raw provider configuration: %s", b) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestMissingReasoningWarnStateSeparatesConfigurationFingerprints(t *testing.T) { |
| 57 | dir := t.TempDir() |
| 58 | s := newMissingReasoningWarnState(dir) |
| 59 | now := missingReasoningTestNow() |
| 60 | if !s.claimAt(warningFingerprint("endpoint-a\x00model-a"), now) { |
| 61 | t.Fatal("first configuration must warn") |
| 62 | } |
| 63 | if !s.claimAt(warningFingerprint("endpoint-a\x00model-b"), now) { |
| 64 | t.Fatal("model change must re-arm the warning") |
| 65 | } |
| 66 | if !s.claimAt(warningFingerprint("endpoint-b\x00model-a"), now) { |
| 67 | t.Fatal("endpoint change must re-arm the warning") |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestMissingReasoningWarnStateExpiresCooldown(t *testing.T) { |
| 72 | s := newMissingReasoningWarnState(t.TempDir()) |
| 73 | fingerprint := warningFingerprint("config") |
| 74 | now := missingReasoningTestNow() |
| 75 | if !s.claimAt(fingerprint, now) { |
| 76 | t.Fatal("fresh incident must warn") |
| 77 | } |
| 78 | if s.claimAt(fingerprint, now.Add(missingReasoningWarnStateCooldown-time.Second)) { |
| 79 | t.Fatal("incident inside cooldown must stay silent") |
| 80 | } |
| 81 | if !s.claimAt(fingerprint, now.Add(missingReasoningWarnStateCooldown)) { |
| 82 | t.Fatal("incident at cooldown boundary must warn again") |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestMissingReasoningWarnStateHealthyTurnRearmsRegression(t *testing.T) { |
| 87 | s := newMissingReasoningWarnState(t.TempDir()) |
| 88 | fingerprint := warningFingerprint("config") |
| 89 | now := missingReasoningTestNow() |
| 90 | if !s.claimAt(fingerprint, now) { |
| 91 | t.Fatal("fresh incident must warn") |
| 92 | } |
| 93 | for healthy := 1; healthy <= missingReasoningHealthyResolveStreak; healthy++ { |
| 94 | result := s.resolveAt(fingerprint, now.Add(time.Duration(healthy)*time.Minute)) |
| 95 | if !result.Recorded { |
| 96 | t.Fatalf("healthy observation %d was not recorded", healthy) |
| 97 | } |
| 98 | if got, want := result.Resolved, healthy == missingReasoningHealthyResolveStreak; got != want { |
| 99 | t.Fatalf("healthy observation %d resolved = %v, want %v", healthy, got, want) |
| 100 | } |
| 101 | } |
| 102 | if !s.claimAt(fingerprint, now.Add(4*time.Minute)) { |
| 103 | t.Fatal("regression after three healthy turns must warn again") |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestMissingReasoningWarnStateMissingTurnResetsHealthyStreak(t *testing.T) { |
| 108 | s := newMissingReasoningWarnState(t.TempDir()) |
| 109 | fingerprint := warningFingerprint("config") |
| 110 | now := missingReasoningTestNow() |
| 111 | if !s.claimAt(fingerprint, now) { |
| 112 | t.Fatal("fresh incident must warn") |
| 113 | } |
| 114 | for healthy := 1; healthy < missingReasoningHealthyResolveStreak; healthy++ { |
| 115 | if result := s.resolveAt(fingerprint, now.Add(time.Duration(healthy)*time.Minute)); !result.Recorded || result.Resolved { |
| 116 | t.Fatalf("pre-reset healthy observation %d = %+v", healthy, result) |
| 117 | } |
| 118 | } |
| 119 | if s.claimAt(fingerprint, now.Add(3*time.Minute)) { |
| 120 | t.Fatal("missing turn inside the active incident must stay suppressed") |
| 121 | } |
| 122 | for healthy := 1; healthy < missingReasoningHealthyResolveStreak; healthy++ { |
| 123 | result := s.resolveAt(fingerprint, now.Add(time.Duration(3+healthy)*time.Minute)) |
| 124 | if !result.Recorded || result.Resolved { |
| 125 | t.Fatalf("post-reset healthy observation %d = %+v", healthy, result) |
| 126 | } |
| 127 | } |
| 128 | if s.claimAt(fingerprint, now.Add(6*time.Minute)) { |
| 129 | t.Fatal("two healthy turns after a reset must not re-arm recovery") |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestMissingReasoningWarnStateStaleHealthCannotClearNewerFailure(t *testing.T) { |
| 134 | s := newMissingReasoningWarnState(t.TempDir()) |
| 135 | fingerprint := warningFingerprint("config") |
| 136 | now := missingReasoningTestNow() |
| 137 | if !s.claimAt(fingerprint, now) { |
| 138 | t.Fatal("fresh incident must warn") |
| 139 | } |
| 140 | if s.claimAt(fingerprint, now.Add(2*time.Millisecond)) { |
| 141 | t.Fatal("newer observation inside cooldown must stay silent") |
| 142 | } |
| 143 | // Simulate an older healthy observation acquiring the lock after the newer |
| 144 | // missing observation. It must not erase the newer incident. |
| 145 | s.resolveAt(fingerprint, now.Add(time.Millisecond)) |
| 146 | if s.claimAt(fingerprint, now.Add(3*time.Millisecond)) { |
| 147 | t.Fatal("stale healthy observation erased a newer incident") |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestMissingReasoningWarnStateDuplicateHealthAndDelayedFailureDoNotChangeStreak(t *testing.T) { |
| 152 | s := newMissingReasoningWarnState(t.TempDir()) |
| 153 | fingerprint := warningFingerprint("config") |
| 154 | now := missingReasoningTestNow() |
| 155 | if !s.persistClaimAt(fingerprint, now) { |
| 156 | t.Fatal("fresh incident must warn") |
| 157 | } |
| 158 | firstHealthyAt := now.Add(2 * time.Millisecond) |
| 159 | if result := s.resolveAt(fingerprint, firstHealthyAt); !result.Recorded || result.Resolved { |
| 160 | t.Fatalf("first healthy observation = %+v", result) |
| 161 | } |
| 162 | if result := s.resolveAt(fingerprint, firstHealthyAt); !result.Recorded || result.Resolved { |
| 163 | t.Fatalf("duplicate healthy observation = %+v", result) |
| 164 | } |
| 165 | if s.persistClaimAt(fingerprint, now.Add(time.Millisecond)) { |
| 166 | t.Fatal("delayed failure older than healthy progress revived the incident") |
| 167 | } |
| 168 | if result := s.resolveAt(fingerprint, now.Add(3*time.Millisecond)); !result.Recorded || result.Resolved { |
| 169 | t.Fatalf("second unique healthy observation = %+v", result) |
| 170 | } |
| 171 | if result := s.resolveAt(fingerprint, now.Add(4*time.Millisecond)); !result.Recorded || !result.Resolved { |
| 172 | t.Fatalf("third unique healthy observation = %+v", result) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestMissingReasoningWarnStateDelayedFailureCannotReviveResolvedIncident(t *testing.T) { |
| 177 | s := newMissingReasoningWarnState(t.TempDir()) |
| 178 | fingerprint := warningFingerprint("config") |
| 179 | now := time.Now() |
| 180 | firstMissingAt := now.Add(-10 * time.Millisecond) |
| 181 | delayedMissingAt := now.Add(-8 * time.Millisecond) |
| 182 | healthyAt := []time.Time{ |
| 183 | now.Add(-6 * time.Millisecond), |
| 184 | now.Add(-4 * time.Millisecond), |
| 185 | now.Add(-2 * time.Millisecond), |
| 186 | } |
| 187 | |
| 188 | if !s.persistClaimAt(fingerprint, firstMissingAt) { |
| 189 | t.Fatal("fresh incident must warn") |
| 190 | } |
| 191 | for i, observedAt := range healthyAt { |
| 192 | result := s.resolveAt(fingerprint, observedAt) |
| 193 | if !result.Recorded || result.Resolved != (i == len(healthyAt)-1) { |
| 194 | t.Fatalf("healthy observation %d = %+v", i+1, result) |
| 195 | } |
| 196 | } |
| 197 | // Simulate a missing observation that happened before the healthy result but |
| 198 | // completed its cross-process transaction afterward. |
| 199 | if s.persistClaimAt(fingerprint, delayedMissingAt) { |
| 200 | t.Fatal("delayed pre-recovery failure revived a resolved incident") |
| 201 | } |
| 202 | if !s.claimAt(fingerprint, now) { |
| 203 | t.Fatal("healthy result did not re-arm a later regression") |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func TestMissingReasoningWarnStateV2OptionalStreakFieldsResume(t *testing.T) { |
| 208 | dir := t.TempDir() |
| 209 | path := filepath.Join(dir, missingReasoningWarnStateFilename) |
| 210 | fingerprint := warningFingerprint("config") |
| 211 | now := missingReasoningTestNow() |
| 212 | doc := fmt.Sprintf(`{"version":2,"incidents":[{"fingerprint":"%s","warnedAtUnixMs":%d,"lastMissingAtUnixMs":%d,"lastMissingAtUnixNano":%d,"resolveStreak":2,"lastHealthyAtUnixNano":%d}]}`, |
| 213 | fingerprint, now.UnixMilli(), now.UnixMilli(), now.UnixNano(), now.Add(2*time.Minute).UnixNano()) |
| 214 | if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | |
| 218 | s := newMissingReasoningWarnState(dir) |
| 219 | result := s.resolveAt(fingerprint, now.Add(3*time.Minute)) |
| 220 | if !result.Recorded || !result.Resolved { |
| 221 | t.Fatalf("resumed third healthy observation = %+v", result) |
| 222 | } |
| 223 | if !s.claimAt(fingerprint, now.Add(4*time.Minute)) { |
| 224 | t.Fatal("resumed v2 streak did not re-arm a later regression") |
| 225 | } |
| 226 | b, err := os.ReadFile(path) |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | if !strings.Contains(string(b), `"version":2`) { |
| 231 | t.Fatalf("optional fields changed the v2 document contract: %s", b) |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | func TestMissingReasoningWarnStateFutureLastMissingSelfHeals(t *testing.T) { |
| 236 | dir := t.TempDir() |
| 237 | path := filepath.Join(dir, missingReasoningWarnStateFilename) |
| 238 | fingerprint := warningFingerprint("config") |
| 239 | now := time.Now().Truncate(time.Millisecond) |
| 240 | doc := missingReasoningWarnDocument{ |
| 241 | Version: missingReasoningWarnStateVersion, |
| 242 | Incidents: []missingReasoningIncident{{ |
| 243 | Fingerprint: fingerprint, |
| 244 | WarnedAtUnixMs: now.UnixMilli(), |
| 245 | LastMissingUnixMs: now.Add(time.Hour).UnixMilli(), |
| 246 | }}, |
| 247 | } |
| 248 | b, err := json.Marshal(doc) |
| 249 | if err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | if err := os.WriteFile(path, b, 0o600); err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | |
| 256 | s := newMissingReasoningWarnState(dir) |
| 257 | s.resolveAt(fingerprint, now.Add(time.Minute)) |
| 258 | if !s.claimAt(fingerprint, now.Add(2*time.Minute)) { |
| 259 | t.Fatal("future last-missing timestamp suppressed a re-armed regression") |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | func TestMissingReasoningWarnStateLegacyPreviewRearmsAndMigrates(t *testing.T) { |
| 264 | dir := t.TempDir() |
| 265 | path := filepath.Join(dir, missingReasoningWarnStateFilename) |
| 266 | if err := os.WriteFile(path, []byte(`{"providers":["deepseek"]}`), 0o600); err != nil { |
| 267 | t.Fatalf("seed legacy state: %v", err) |
| 268 | } |
| 269 | s := newMissingReasoningWarnState(dir) |
| 270 | if !s.claimAt(warningFingerprint("deepseek-current-config"), missingReasoningTestNow()) { |
| 271 | t.Fatal("legacy provider-name marker must not suppress a configuration-scoped incident") |
| 272 | } |
| 273 | b, err := os.ReadFile(path) |
| 274 | if err != nil { |
| 275 | t.Fatal(err) |
| 276 | } |
| 277 | if strings.Contains(string(b), `"providers"`) || !strings.Contains(string(b), `"version":2`) { |
| 278 | t.Fatalf("legacy state was not migrated to v2: %s", b) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | func TestMissingReasoningWarnStateLoadsV2IncidentWithoutNanosecondField(t *testing.T) { |
| 283 | dir := t.TempDir() |
| 284 | path := filepath.Join(dir, missingReasoningWarnStateFilename) |
| 285 | fingerprint := warningFingerprint("config") |
| 286 | now := missingReasoningTestNow() |
| 287 | doc := fmt.Sprintf(`{"version":2,"incidents":[{"fingerprint":"%s","warnedAtUnixMs":%d,"lastMissingAtUnixMs":%d}]}`, |
| 288 | fingerprint, now.UnixMilli(), now.UnixMilli()) |
| 289 | if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { |
| 290 | t.Fatal(err) |
| 291 | } |
| 292 | |
| 293 | s := newMissingReasoningWarnState(dir) |
| 294 | if s.claimAt(fingerprint, now.Add(time.Minute)) { |
| 295 | t.Fatal("v2 incident without nanosecond fields did not retain its active warning") |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | func TestMissingReasoningWarnStateCorruptFileSelfHeals(t *testing.T) { |
| 300 | dir := t.TempDir() |
| 301 | path := filepath.Join(dir, missingReasoningWarnStateFilename) |
| 302 | if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { |
| 303 | t.Fatalf("seed corrupt file: %v", err) |
| 304 | } |
| 305 | fingerprint := warningFingerprint("config") |
| 306 | s := newMissingReasoningWarnState(dir) |
| 307 | now := missingReasoningTestNow() |
| 308 | if !s.claimAt(fingerprint, now) { |
| 309 | t.Fatal("corrupt state must re-arm the incident") |
| 310 | } |
| 311 | if s.claimAt(fingerprint, now.Add(time.Minute)) { |
| 312 | t.Fatal("rewritten state did not retain the incident") |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | func TestMissingReasoningWarnStateUsesOwnerOnlyPermissions(t *testing.T) { |
| 317 | dir := filepath.Join(t.TempDir(), "state") |
| 318 | s := newMissingReasoningWarnState(dir) |
| 319 | if !s.claimAt(warningFingerprint("config"), missingReasoningTestNow()) { |
| 320 | t.Fatal("fresh incident must warn") |
| 321 | } |
| 322 | dirInfo, err := os.Stat(dir) |
| 323 | if err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | if got := dirInfo.Mode().Perm(); runtime.GOOS != "windows" && got != 0o700 { |
| 327 | t.Fatalf("state directory mode = %o, want 700", got) |
| 328 | } |
| 329 | fileInfo, err := os.Stat(filepath.Join(dir, missingReasoningWarnStateFilename)) |
| 330 | if err != nil { |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | if got := fileInfo.Mode().Perm(); runtime.GOOS != "windows" && got != 0o600 { |
| 334 | t.Fatalf("state file mode = %o, want 600", got) |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | func TestMissingReasoningWarnStateIOFailureFallsBackVisible(t *testing.T) { |
| 339 | path := filepath.Join(t.TempDir(), "not-a-directory") |
| 340 | if err := os.WriteFile(path, []byte("occupied"), 0o600); err != nil { |
| 341 | t.Fatal(err) |
| 342 | } |
| 343 | if !newMissingReasoningWarnState(path).claimAt(warningFingerprint("config"), missingReasoningTestNow()) { |
| 344 | t.Fatal("state I/O failure must keep the diagnostic visible") |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | func TestMissingReasoningWarnStateReadFailureDoesNotOverwriteExistingIncidents(t *testing.T) { |
| 349 | if runtime.GOOS == "windows" { |
| 350 | t.Skip("chmod permissions are not portable to Windows") |
| 351 | } |
| 352 | dir := t.TempDir() |
| 353 | path := filepath.Join(dir, missingReasoningWarnStateFilename) |
| 354 | s := newMissingReasoningWarnState(dir) |
| 355 | now := missingReasoningTestNow() |
| 356 | existingFingerprint := warningFingerprint("existing") |
| 357 | newFingerprint := warningFingerprint("new") |
| 358 | if !s.claimAt(existingFingerprint, now) { |
| 359 | t.Fatal("fresh existing incident must warn") |
| 360 | } |
| 361 | if err := os.Chmod(path, 0); err != nil { |
| 362 | t.Fatal(err) |
| 363 | } |
| 364 | permissionsRestored := false |
| 365 | defer func() { |
| 366 | if !permissionsRestored { |
| 367 | _ = os.Chmod(path, 0o600) |
| 368 | } |
| 369 | }() |
| 370 | if !s.claimAt(newFingerprint, now.Add(time.Minute)) { |
| 371 | t.Fatal("state read failure must keep the new diagnostic visible") |
| 372 | } |
| 373 | if err := os.Chmod(path, 0o600); err != nil { |
| 374 | t.Fatal(err) |
| 375 | } |
| 376 | permissionsRestored = true |
| 377 | |
| 378 | incidents, err := s.load(now.Add(2 * time.Minute)) |
| 379 | if err != nil { |
| 380 | t.Fatal(err) |
| 381 | } |
| 382 | if _, ok := incidents[existingFingerprint]; !ok { |
| 383 | t.Fatal("state read failure overwrote the existing incident") |
| 384 | } |
| 385 | if _, ok := incidents[newFingerprint]; ok { |
| 386 | t.Fatal("new incident was unexpectedly persisted from a partial read") |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | func TestMissingReasoningWarnStateEmptyDirFallsBackVisible(t *testing.T) { |
| 391 | s := newMissingReasoningWarnState("") |
| 392 | fingerprint := warningFingerprint("config") |
| 393 | if !s.claim(fingerprint) { |
| 394 | t.Fatal("first empty-dir claim must stay visible") |
| 395 | } |
| 396 | if !s.claim(fingerprint) { |
| 397 | t.Fatal("repeated empty-dir claim must stay visible") |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | func TestMissingReasoningWarnStateConcurrentSameIncidentWarnsOnce(t *testing.T) { |
| 402 | dir := t.TempDir() |
| 403 | fingerprint := warningFingerprint("shared-config") |
| 404 | now := missingReasoningTestNow() |
| 405 | start := make(chan struct{}) |
| 406 | var warned atomic.Int64 |
| 407 | var wg sync.WaitGroup |
| 408 | for range 8 { |
| 409 | wg.Add(1) |
| 410 | go func() { |
| 411 | defer wg.Done() |
| 412 | <-start |
| 413 | if newMissingReasoningWarnState(dir).claimAt(fingerprint, now) { |
| 414 | warned.Add(1) |
| 415 | } |
| 416 | }() |
| 417 | } |
| 418 | close(start) |
| 419 | wg.Wait() |
| 420 | if got := warned.Load(); got != 1 { |
| 421 | t.Fatalf("concurrent first warnings = %d, want 1", got) |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | func TestMissingReasoningWarnStateConcurrentFollowerPersistsLatestObservation(t *testing.T) { |
| 426 | dir := t.TempDir() |
| 427 | s := newMissingReasoningWarnState(dir) |
| 428 | fingerprint := warningFingerprint("shared-config") |
| 429 | firstObservedAt := missingReasoningTestNow() |
| 430 | latestObservedAt := firstObservedAt.Add(2 * time.Millisecond) |
| 431 | |
| 432 | releaseLock, err := filelock.Acquire(context.Background(), s.lockPath()) |
| 433 | if err != nil { |
| 434 | t.Fatalf("hold state lock: %v", err) |
| 435 | } |
| 436 | released := false |
| 437 | defer func() { |
| 438 | if !released { |
| 439 | releaseLock() |
| 440 | } |
| 441 | }() |
| 442 | |
| 443 | leaderResult := make(chan bool, 1) |
| 444 | go func() { |
| 445 | leaderResult <- s.claimAt(fingerprint, firstObservedAt) |
| 446 | }() |
| 447 | |
| 448 | key := s.claimFlightKey(fingerprint) |
| 449 | deadline := time.Now().Add(missingReasoningWarnStateLockTimeout / 2) |
| 450 | for { |
| 451 | missingReasoningWarnClaimFlights.Lock() |
| 452 | flightPresent := missingReasoningWarnClaimFlights.flights[key] != nil |
| 453 | missingReasoningWarnClaimFlights.Unlock() |
| 454 | if flightPresent { |
| 455 | break |
| 456 | } |
| 457 | if time.Now().After(deadline) { |
| 458 | t.Fatal("leader did not register its claim flight") |
| 459 | } |
| 460 | time.Sleep(time.Millisecond) |
| 461 | } |
| 462 | |
| 463 | if s.claimAt(fingerprint, latestObservedAt) { |
| 464 | t.Fatal("concurrent follower must not emit a duplicate warning") |
| 465 | } |
| 466 | releaseLock() |
| 467 | released = true |
| 468 | if !<-leaderResult { |
| 469 | t.Fatal("leader must keep the first incident warning visible") |
| 470 | } |
| 471 | |
| 472 | incidents, err := s.load(latestObservedAt) |
| 473 | if err != nil { |
| 474 | t.Fatal(err) |
| 475 | } |
| 476 | incident, ok := incidents[fingerprint] |
| 477 | if !ok || len(incidents) != 1 { |
| 478 | t.Fatalf("persisted incidents = %#v, want only %q", incidents, fingerprint) |
| 479 | } |
| 480 | if got, want := incident.LastMissingUnixMs, latestObservedAt.UnixMilli(); got != want { |
| 481 | t.Fatalf("last missing timestamp = %d, want %d", got, want) |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | func TestMissingReasoningWarnStateConcurrentClaimsKeepEveryConfiguration(t *testing.T) { |
| 486 | dir := t.TempDir() |
| 487 | now := missingReasoningTestNow() |
| 488 | labels := []string{"alpha", "bravo", "charlie", "delta"} |
| 489 | start := make(chan struct{}) |
| 490 | var wg sync.WaitGroup |
| 491 | for _, label := range labels { |
| 492 | fingerprint := warningFingerprint(label) |
| 493 | wg.Add(1) |
| 494 | go func() { |
| 495 | defer wg.Done() |
| 496 | <-start |
| 497 | if !newMissingReasoningWarnState(dir).claimAt(fingerprint, now) { |
| 498 | t.Errorf("fresh configuration %q did not claim its notice", label) |
| 499 | } |
| 500 | }() |
| 501 | } |
| 502 | close(start) |
| 503 | wg.Wait() |
| 504 | |
| 505 | fresh := newMissingReasoningWarnState(dir) |
| 506 | for _, label := range labels { |
| 507 | if fresh.claimAt(warningFingerprint(label), now.Add(time.Minute)) { |
| 508 | t.Errorf("configuration %q was lost after concurrent claims", label) |
| 509 | } |
| 510 | } |
| 511 | } |
| 512 |