返回 DeepSeek-Reasonix
crash_pending.go
根目录 / desktop / crash_pending.go
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "runtime/debug"
9 "sort"
10 "strconv"
11 "sync"
12 "sync/atomic"
13 "time"
14
15 "reasonix/internal/config"
16 )
17
18 // crash_pending.go captures Go-side panics to disk and ships them on the next
19 // launch. Frontend crashes are click-to-send, but an unrecovered Go panic kills the
20 // process before the user can react, so the whole agent/provider/tool layer would
21 // otherwise never surface a single report. The resend is gated on the same
22 // desktop.telemetry opt-out as the launch ping.
23
24 const (
25 pendingCrashFile = "crash-pending.json" // legacy single-report path
26 pendingCrashQueueDir = "crash-pending"
27 maxPendingCrashes = 10
28 )
29
30 var (
31 pendingCrashMu sync.Mutex
32 pendingCrashSequence atomic.Uint64
33 )
34
35 func pendingCrashPath() string {
36 return filepath.Join(config.MemoryUserDir(), pendingCrashFile)
37 }
38
39 func pendingCrashDir() string {
40 return filepath.Join(config.MemoryUserDir(), pendingCrashQueueDir)
41 }
42
43 // recoverToPending records a panicking goroutine to the pending-crash file and
44 // re-raises, so the process still crashes exactly as before — the stack is now
45 // shipped next launch instead of lost.
46 func (a *App) recoverToPending(site string) {
47 r := recover()
48 if r == nil {
49 return
50 }
51 writePendingCrash(site, r, debug.Stack())
52 panic(r)
53 }
54
55 func writePendingCrash(site string, r any, stack []byte) {
56 stackText := string(stack)
57 msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s\n\n%s", site, stackText), maxCrashDetailBytes)
58 report := baseCrashReport("crash")
59 report.SchemaVersion = 2
60 report.Source = "go"
61 report.Label = sanitizeCrashField(site, 64)
62 report.ErrorType = sanitizeCrashField(fmt.Sprintf("%T", r), 128)
63 report.ErrorMessage = sanitizeCrashText("Go panic captured at "+site+".", maxCrashFieldBytes)
64 report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
65 report.TopFrame = topFrameFromStack(report.Stack)
66 report.Message = msg
67 if writePendingReport(report, true) {
68 markFatalCrashCovered()
69 }
70 }
71
72 func writePendingReport(report crashReport, overwrite bool) bool {
73 _ = overwrite // retained for source compatibility; the queue never overwrites.
74 body, err := json.Marshal(report)
75 if err != nil {
76 return false
77 }
78 dir := pendingCrashDir()
79 if os.MkdirAll(dir, 0o700) != nil {
80 return false
81 }
82 pendingCrashMu.Lock()
83 defer pendingCrashMu.Unlock()
84 name := strconv.FormatInt(time.Now().UTC().UnixNano(), 10) + "-" +
85 strconv.Itoa(os.Getpid()) + "-" +
86 strconv.FormatUint(pendingCrashSequence.Add(1), 10) + ".json"
87 path := filepath.Join(dir, name)
88 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
89 if err != nil {
90 return false
91 }
92 n, err := f.Write(body)
93 closeErr := f.Close()
94 if err != nil || closeErr != nil || n != len(body) {
95 _ = os.Remove(path)
96 return false
97 }
98 paths := pendingCrashQueuePaths()
99 for len(paths) > maxPendingCrashes {
100 _ = os.Remove(paths[0])
101 paths = paths[1:]
102 }
103 return true
104 }
105
106 func pendingCrashQueuePaths() []string {
107 entries, err := os.ReadDir(pendingCrashDir())
108 if err != nil {
109 return nil
110 }
111 paths := make([]string, 0, len(entries))
112 for _, entry := range entries {
113 if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
114 paths = append(paths, filepath.Join(pendingCrashDir(), entry.Name()))
115 }
116 }
117 sort.Strings(paths)
118 return paths
119 }
120
121 func pendingCrashPaths() []string {
122 paths := make([]string, 0, maxPendingCrashes+1)
123 if _, err := os.Stat(pendingCrashPath()); err == nil {
124 paths = append(paths, pendingCrashPath())
125 }
126 return append(paths, pendingCrashQueuePaths()...)
127 }
128
129 func removeAllPendingCrashes() {
130 _ = os.Remove(pendingCrashPath())
131 _ = os.RemoveAll(pendingCrashDir())
132 _ = os.Remove(fatalCrashCoveredPath())
133 }
134
135 func (a *App) goSafe(site string, fn func()) {
136 go func() {
137 defer a.recoverToPending(site)
138 fn()
139 }()
140 }
141
142 // flushPendingCrash drains a Go panic captured on a prior run and POSTs it, then
143 // clears it. Runs at launch alongside the ping; honours the telemetry opt-out by
144 // dropping the file unsent.
145 func (a *App) flushPendingCrash() {
146 if version == "dev" {
147 return
148 }
149 paths := pendingCrashPaths()
150 if len(paths) == 0 {
151 return
152 }
153 cfg, err := config.Load()
154 if err != nil {
155 return
156 }
157 if !cfg.DesktopTelemetry() {
158 removeAllPendingCrashes()
159 return
160 }
161 c, err := httpClient()
162 if err != nil {
163 return
164 }
165 for _, path := range paths {
166 body, readErr := readFileUTF8(path)
167 if readErr != nil {
168 continue
169 }
170 var r crashReport
171 if json.Unmarshal(body, &r) != nil {
172 _ = os.Remove(path)
173 continue
174 }
175 if postCrashReport(a.bootContext(), c, crashEndpoint, r) != nil {
176 break
177 }
178 _ = os.Remove(path)
179 }
180 _ = os.Remove(pendingCrashDir())
181 }
182
182 lines GO