返回 DeepSeek-Reasonix
crash_pending_test.go
根目录 / desktop / crash_pending_test.go
1 package main
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "testing"
16 "time"
17 )
18
19 func readPending(t *testing.T) (crashReport, bool) {
20 t.Helper()
21 paths := pendingCrashPaths()
22 if len(paths) == 0 {
23 return crashReport{}, false
24 }
25 body, err := os.ReadFile(paths[0])
26 if err != nil {
27 return crashReport{}, false
28 }
29 var r crashReport
30 if err := json.Unmarshal(body, &r); err != nil {
31 t.Fatalf("pending file not valid JSON: %v", err)
32 }
33 return r, true
34 }
35
36 func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
37 t.Cleanup(removeAllPendingCrashes)
38
39 func() {
40 defer func() {
41 if recover() == nil {
42 t.Fatal("recoverToPending must re-raise the panic")
43 }
44 }()
45 app := NewApp()
46 defer app.recoverToPending("unit")
47 panic(`boom at C:\Users\alice\proj\x.go`)
48 }()
49
50 r, ok := readPending(t)
51 if !ok {
52 t.Fatal("expected a pending crash file")
53 }
54 if r.Kind != "crash" {
55 t.Errorf("kind = %q, want crash", r.Kind)
56 }
57 if !strings.Contains(r.Message, "[go panic] unit") {
58 t.Errorf("message missing site prefix: %q", r.Message)
59 }
60 if r.Source != "go" || r.Label != "unit" || r.ErrorMessage == "" || r.Stack == "" || r.TopFrame == "" {
61 t.Errorf("structured panic metadata missing: %+v", r)
62 }
63 if strings.Contains(r.Message, `Users\alice`) {
64 t.Errorf("message not scrubbed: %q", r.Message)
65 }
66 }
67
68 func TestWritePendingCrashCaps(t *testing.T) {
69 t.Cleanup(removeAllPendingCrashes)
70 writePendingCrash("big", "x", []byte(strings.Repeat("a", 64<<10)))
71 r, ok := readPending(t)
72 if !ok {
73 t.Fatal("expected a pending crash file")
74 }
75 if len(r.Message) > maxCrashDetailBytes {
76 t.Errorf("message len = %d, want <= %d", len(r.Message), maxCrashDetailBytes)
77 }
78 }
79
80 func TestWritePendingReportQueuesWithoutOverwritingExistingCrash(t *testing.T) {
81 t.Cleanup(removeAllPendingCrashes)
82 writePendingCrash("panic", "boom", []byte("stack"))
83 before, ok := readPending(t)
84 if !ok {
85 t.Fatal("expected initial pending crash")
86 }
87
88 hang := baseCrashReport("performance")
89 hang.Source = "native.watchdog"
90 hang.Label = "mac.main_thread.hang"
91 hang.Message = "hang"
92 if !writePendingReport(hang, false) {
93 t.Fatal("writePendingReport should enqueue the second report")
94 }
95 after, ok := readPending(t)
96 if !ok {
97 t.Fatal("expected pending crash after skipped write")
98 }
99 if after.Label != before.Label || after.Message != before.Message {
100 t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
101 }
102 if got := len(pendingCrashPaths()); got != 2 {
103 t.Fatalf("pending reports = %d, want 2", got)
104 }
105 }
106
107 func TestWritePendingReportQueueIsBoundedUnderConcurrentWriters(t *testing.T) {
108 t.Cleanup(removeAllPendingCrashes)
109 const writers = 32
110 start := make(chan struct{})
111 var ready sync.WaitGroup
112 var done sync.WaitGroup
113 var successes atomic.Int32
114
115 for range writers {
116 ready.Add(1)
117 done.Go(func() {
118 report := baseCrashReport("performance")
119 report.Source = "native.watchdog"
120 report.Label = "mac.main_thread.hang"
121 report.Message = strings.Repeat("hang", 1024)
122 ready.Done()
123 <-start
124 if writePendingReport(report, false) {
125 successes.Add(1)
126 }
127 })
128 }
129 ready.Wait()
130 close(start)
131 done.Wait()
132
133 if got := successes.Load(); got != writers {
134 t.Fatalf("successful queued writers = %d, want %d", got, writers)
135 }
136 if got := len(pendingCrashPaths()); got != maxPendingCrashes {
137 t.Fatalf("pending reports = %d, want bounded queue of %d", got, maxPendingCrashes)
138 }
139 }
140
141 func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
142 t.Cleanup(removeAllPendingCrashes)
143 apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
144 bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
145 longHex := "0123456789abcdef0123456789abcdef"
146
147 privatePanicValue := "private user prompt contents"
148 writePendingCrash("unit", privatePanicValue+" api_key="+apiKey+" user alice@example.com", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
149 r, ok := readPending(t)
150 if !ok {
151 t.Fatal("expected a pending crash file")
152 }
153 freeText := strings.Join([]string{r.Message, r.ErrorMessage, r.Stack, r.TopFrame}, "\n")
154 for _, leaked := range []string{privatePanicValue, apiKey, bearer, longHex, "alice@example.com", "/home/alice"} {
155 if strings.Contains(freeText, leaked) {
156 t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
157 }
158 }
159 }
160
161 func TestFlushPendingCrashSendsAndClears(t *testing.T) {
162 oldVersion, oldEndpoint := version, crashEndpoint
163 t.Cleanup(func() {
164 version, crashEndpoint = oldVersion, oldEndpoint
165 removeAllPendingCrashes()
166 })
167 version = "v9.9.9"
168
169 var hits atomic.Int32
170 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
171 hits.Add(1)
172 w.WriteHeader(http.StatusAccepted)
173 }))
174 defer srv.Close()
175 crashEndpoint = srv.URL
176
177 writePendingCrash("flush", "boom", []byte("stack"))
178 NewApp().flushPendingCrash()
179
180 if hits.Load() != 1 {
181 t.Errorf("server hits = %d, want 1", hits.Load())
182 }
183 if _, ok := readPending(t); ok {
184 t.Error("pending file should be cleared after a successful send")
185 }
186 }
187
188 func TestFlushPendingCrashDevGuard(t *testing.T) {
189 oldVersion := version
190 t.Cleanup(func() {
191 version = oldVersion
192 removeAllPendingCrashes()
193 })
194 version = "dev"
195
196 writePendingCrash("dev", "boom", []byte("stack"))
197 NewApp().flushPendingCrash()
198
199 if _, ok := readPending(t); !ok {
200 t.Error("dev build must leave the pending file untouched")
201 }
202 }
203
204 func TestFlushPendingCrashIgnoresSafeModeEnv(t *testing.T) {
205 // v1.20+: REASONIX_SAFE_MODE no longer blocks crash flush.
206 t.Setenv("REASONIX_SAFE_MODE", "1")
207 oldVersion, oldEndpoint := version, crashEndpoint
208 t.Cleanup(func() {
209 version = oldVersion
210 crashEndpoint = oldEndpoint
211 removeAllPendingCrashes()
212 })
213 version = "v9.9.9"
214 var hits atomic.Int32
215 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
216 hits.Add(1)
217 w.WriteHeader(http.StatusAccepted)
218 }))
219 defer srv.Close()
220 crashEndpoint = srv.URL
221
222 writePendingCrash("safe", "boom", []byte("stack"))
223 NewApp().flushPendingCrash()
224 if hits.Load() != 1 {
225 t.Fatalf("server hits = %d, want 1", hits.Load())
226 }
227 if _, ok := readPending(t); ok {
228 t.Fatal("safe-mode compatibility left a sent report pending")
229 }
230 }
231
232 func TestPendingCrashDoesNotPersistInstallID(t *testing.T) {
233 removeAllPendingCrashes()
234 t.Cleanup(removeAllPendingCrashes)
235 report := baseCrashReport("crash")
236 report.Message = "pending"
237 if !writePendingReport(report, true) {
238 t.Fatal("writePendingReport failed")
239 }
240 paths := pendingCrashQueuePaths()
241 if len(paths) != 1 {
242 t.Fatalf("pending paths = %v", paths)
243 }
244 body, err := os.ReadFile(paths[0])
245 if err != nil {
246 t.Fatal(err)
247 }
248 if bytes.Contains(body, []byte("installId")) || bytes.Contains(body, []byte("install-id")) {
249 t.Fatalf("pending report persisted an installation identity: %s", body)
250 }
251 }
252
253 func TestFlushPendingCrashDeduplicatesEventIDWithoutCollapsingSimilarEvents(t *testing.T) {
254 removeAllPendingCrashes()
255 oldVersion, oldEndpoint := version, crashEndpoint
256 t.Cleanup(func() {
257 version, crashEndpoint = oldVersion, oldEndpoint
258 removeAllPendingCrashes()
259 })
260 version = "v9.9.9"
261 var hits atomic.Int32
262 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
263 hits.Add(1)
264 w.WriteHeader(http.StatusAccepted)
265 }))
266 defer srv.Close()
267 crashEndpoint = srv.URL
268
269 report := baseCrashReport("crash")
270 report.Source = "go"
271 report.ErrorType = "panic"
272 report.TopFrame = "main.go:12"
273 report.Message = "same crash"
274 report.EventID = "11111111111111111111111111111111"
275 firstQueued := writePendingReport(report, false)
276 secondQueued := writePendingReport(report, false)
277 if !firstQueued || !secondQueued {
278 t.Fatal("failed to queue duplicate reports")
279 }
280 NewApp().flushPendingCrash()
281 if got := hits.Load(); got != 1 {
282 t.Fatalf("same-event uploads = %d, want 1", got)
283 }
284
285 report.EventID = "22222222222222222222222222222222"
286 if !writePendingReport(report, false) {
287 t.Fatal("failed to queue distinct similar event")
288 }
289 NewApp().flushPendingCrash()
290 if got := hits.Load(); got != 2 {
291 t.Fatalf("distinct similar-event uploads = %d, want 2", got)
292 }
293 info, err := os.Stat(crashLedgerPath())
294 if err != nil {
295 t.Fatal(err)
296 }
297 if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
298 t.Fatalf("ledger permissions = %o, want 600", info.Mode().Perm())
299 }
300 }
301
302 func TestConcurrentFlushPendingCrashUsesOneCrossProcessLedgerOwner(t *testing.T) {
303 removeAllPendingCrashes()
304 oldVersion, oldEndpoint := version, crashEndpoint
305 t.Cleanup(func() {
306 version, crashEndpoint = oldVersion, oldEndpoint
307 removeAllPendingCrashes()
308 })
309 version = "v9.9.9"
310 var hits atomic.Int32
311 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
312 hits.Add(1)
313 w.WriteHeader(http.StatusAccepted)
314 }))
315 defer srv.Close()
316 crashEndpoint = srv.URL
317 writePendingCrash("concurrent", "boom", []byte("stack"))
318
319 start := make(chan struct{})
320 var done sync.WaitGroup
321 for range 2 {
322 done.Go(func() {
323 <-start
324 NewApp().flushPendingCrash()
325 })
326 }
327 close(start)
328 done.Wait()
329 if got := hits.Load(); got != 1 {
330 t.Fatalf("concurrent uploads = %d, want 1", got)
331 }
332 }
333
334 func TestFlushPendingCrashFailureDoesNotRecordOrDelete(t *testing.T) {
335 removeAllPendingCrashes()
336 oldVersion, oldEndpoint := version, crashEndpoint
337 t.Cleanup(func() {
338 version, crashEndpoint = oldVersion, oldEndpoint
339 removeAllPendingCrashes()
340 })
341 version = "v9.9.9"
342 status := atomic.Int32{}
343 status.Store(http.StatusServiceUnavailable)
344 var hits atomic.Int32
345 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
346 hits.Add(1)
347 w.WriteHeader(int(status.Load()))
348 }))
349 defer srv.Close()
350 crashEndpoint = srv.URL
351 writePendingCrash("retry", "boom", []byte("stack"))
352
353 NewApp().flushPendingCrash()
354 if _, ok := readPending(t); !ok {
355 t.Fatal("failed upload removed pending crash")
356 }
357 if ledger := loadCrashLedger(crashLedgerPath(), time.Now().UTC()); len(ledger.Entries) != 0 {
358 t.Fatalf("failed upload recorded ledger entry: %+v", ledger.Entries)
359 }
360 status.Store(http.StatusAccepted)
361 NewApp().flushPendingCrash()
362 if hits.Load() != 2 {
363 t.Fatalf("retry requests = %d, want 2", hits.Load())
364 }
365 if _, ok := readPending(t); ok {
366 t.Fatal("successful retry left pending crash")
367 }
368 }
369
370 func TestFlushPendingCrashBackfillsOldIdentityAndPreservesFutureSchema(t *testing.T) {
371 removeAllPendingCrashes()
372 oldVersion, oldEndpoint := version, crashEndpoint
373 t.Cleanup(func() {
374 version, crashEndpoint = oldVersion, oldEndpoint
375 removeAllPendingCrashes()
376 })
377 version = "v9.9.9"
378 if err := os.MkdirAll(pendingCrashDir(), 0o700); err != nil {
379 t.Fatal(err)
380 }
381 oldPath := filepath.Join(pendingCrashDir(), "001-old.json")
382 futurePath := filepath.Join(pendingCrashDir(), "002-future.json")
383 oldReport := `{"kind":"crash","version":"v9.9.9","os":"linux","arch":"amd64","message":"old","schemaVersion":2,"source":"go","errorType":"panic","topFrame":"main.go:12"}`
384 futureReport := `{"kind":"crash","version":"v10.0.0","os":"linux","arch":"amd64","message":"future","schemaVersion":99,"futureField":"keep"}`
385 if err := os.WriteFile(oldPath, []byte(oldReport), 0o600); err != nil {
386 t.Fatal(err)
387 }
388 if err := os.WriteFile(futurePath, []byte(futureReport), 0o600); err != nil {
389 t.Fatal(err)
390 }
391 var uploaded crashReport
392 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
393 if err := json.NewDecoder(r.Body).Decode(&uploaded); err != nil {
394 t.Error(err)
395 }
396 w.WriteHeader(http.StatusAccepted)
397 }))
398 defer srv.Close()
399 crashEndpoint = srv.URL
400
401 NewApp().flushPendingCrash()
402 if len(uploaded.EventID) != 32 || len(uploaded.DedupKey) != 64 {
403 t.Fatalf("old report identity not backfilled: %+v", uploaded)
404 }
405 if _, err := os.Stat(oldPath); !os.IsNotExist(err) {
406 t.Fatalf("old report was not removed after send: %v", err)
407 }
408 body, err := os.ReadFile(futurePath)
409 if err != nil || !bytes.Contains(body, []byte(`"futureField":"keep"`)) {
410 t.Fatalf("future schema was not preserved: body=%s err=%v", body, err)
411 }
412 }
413
414 func TestFlushPendingCrashUploadsCurrentSchemaThree(t *testing.T) {
415 removeAllPendingCrashes()
416 oldVersion, oldEndpoint := version, crashEndpoint
417 t.Cleanup(func() {
418 version, crashEndpoint = oldVersion, oldEndpoint
419 removeAllPendingCrashes()
420 })
421 version = "v9.9.9"
422 var hits atomic.Int32
423 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
424 hits.Add(1)
425 w.WriteHeader(http.StatusAccepted)
426 }))
427 defer srv.Close()
428 crashEndpoint = srv.URL
429
430 report := desktopLifecycleReport(desktopLifecycleObservation{
431 Version: "v9.9.8",
432 Phase: "ready",
433 })
434 if report.SchemaVersion != currentCrashSchema {
435 t.Fatalf("current producer schema = %d, supported = %d", report.SchemaVersion, currentCrashSchema)
436 }
437 if !writePendingReport(report, false) {
438 t.Fatal("failed to queue current-schema report")
439 }
440 NewApp().flushPendingCrash()
441 if got := hits.Load(); got != 1 {
442 t.Fatalf("current-schema uploads = %d, want 1", got)
443 }
444 if got := len(pendingCrashPaths()); got != 0 {
445 t.Fatalf("pending reports after upload = %d, want 0", got)
446 }
447 }
448
449 func TestWritePendingReportPrunesKnownReportsWithoutDeletingFutureSchema(t *testing.T) {
450 removeAllPendingCrashes()
451 t.Cleanup(removeAllPendingCrashes)
452 if err := os.MkdirAll(pendingCrashDir(), 0o700); err != nil {
453 t.Fatal(err)
454 }
455 futurePath := filepath.Join(pendingCrashDir(), "000-future.json")
456 futureReport := `{"schemaVersion":99,"futureField":"keep"}`
457 if err := os.WriteFile(futurePath, []byte(futureReport), 0o600); err != nil {
458 t.Fatal(err)
459 }
460 for index := range maxPendingCrashes {
461 report := baseCrashReport("crash")
462 report.SchemaVersion = currentCrashSchema
463 report.Source = "go"
464 report.Label = fmt.Sprintf("panic-%d", index)
465 report.Message = "bounded current report"
466 if !writePendingReport(report, false) {
467 t.Fatalf("failed to queue current report %d", index)
468 }
469 }
470 body, err := os.ReadFile(futurePath)
471 if err != nil || !bytes.Contains(body, []byte(`"futureField":"keep"`)) {
472 t.Fatalf("future schema was pruned: body=%s err=%v", body, err)
473 }
474 if got := len(pendingCrashQueuePaths()); got != maxPendingCrashes {
475 t.Fatalf("pending reports = %d, want bounded queue of %d", got, maxPendingCrashes)
476 }
477 }
478
478 lines GO