返回 DeepSeek-Reasonix
crash_pending.go
根目录 / desktop / crash_pending.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime/debug"
10 "sort"
11 "strconv"
12 "sync"
13 "sync/atomic"
14 "time"
15
16 "reasonix/internal/config"
17 "reasonix/internal/fileutil"
18 filelock "reasonix/internal/identitylock"
19 "reasonix/internal/session"
20 )
21
22 // crash_pending.go captures Go-side panics to disk and ships them on the next
23 // launch. Frontend crashes are click-to-send, but an unrecovered Go panic kills the
24 // process before the user can react, so the whole agent/provider/tool layer would
25 // otherwise never surface a single report. The resend is gated on the same
26 // desktop.telemetry opt-out as the launch ping.
27
28 const (
29 pendingCrashFile = "crash-pending.json" // legacy single-report path
30 pendingCrashQueueDir = "crash-pending"
31 maxPendingCrashes = 10
32 currentCrashSchema = 4
33 crashLedgerFile = "crash-upload-ledger-v1.json"
34 maxCrashLedger = 512
35 crashLedgerRetention = 90 * 24 * time.Hour
36 )
37
38 var (
39 pendingCrashMu sync.Mutex
40 pendingCrashSequence atomic.Uint64
41 )
42
43 func pendingCrashPath() string {
44 return filepath.Join(config.MemoryUserDir(), pendingCrashFile)
45 }
46
47 func pendingCrashDir() string {
48 return filepath.Join(config.MemoryUserDir(), pendingCrashQueueDir)
49 }
50
51 // recoverToPending records a panicking goroutine to the pending-crash file and
52 // re-raises, so the process still crashes exactly as before — the stack is now
53 // shipped next launch instead of lost.
54 func (a *App) recoverToPending(site string) {
55 r := recover()
56 if r == nil {
57 return
58 }
59 writePendingCrashWithDiagnostics(site, r, debug.Stack(), a.currentCrashDiagnostics("confirmed", "crash"))
60 panic(r)
61 }
62
63 func writePendingCrash(site string, r any, stack []byte) {
64 writePendingCrashWithDiagnostics(site, r, stack, nil)
65 }
66
67 func (a *App) currentCrashDiagnostics(evidence, category string) *crashDiagnostics {
68 diagnostic := &crashDiagnostics{
69 SubjectVersion: version, SubjectBuildCommit: buildCommit(), SubjectChannel: channel,
70 ObserverVersion: version, ObserverBuildCommit: buildCommit(), ProcessRole: "service",
71 ObservedAt: time.Now().UTC().Format(time.RFC3339Nano), Evidence: evidence, Category: category,
72 }
73 if a != nil && a.lifecycle.tracker != nil {
74 a.lifecycle.tracker.mu.Lock()
75 diagnostic.RunID = a.lifecycle.tracker.state.RunID
76 diagnostic.IncidentID = a.lifecycle.tracker.state.IncidentID
77 diagnostic.LastPhase = a.lifecycle.tracker.state.Phase
78 diagnostic.LastPhaseAt = a.lifecycle.tracker.state.UpdatedAt
79 a.lifecycle.tracker.mu.Unlock()
80 }
81 return diagnostic
82 }
83
84 func writePendingCrashWithDiagnostics(site string, r any, stack []byte, diagnostics *crashDiagnostics) {
85 stackText := string(stack)
86 msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s\n\n%s", site, stackText), maxCrashDetailBytes)
87 report := baseCrashReport("crash")
88 report.SchemaVersion = currentCrashSchema
89 report.Source = "go"
90 report.Label = sanitizeCrashField(site, 64)
91 report.ErrorType = sanitizeCrashField(fmt.Sprintf("%T", r), 128)
92 report.ErrorMessage = sanitizeCrashText("Go panic captured at "+site+".", maxCrashFieldBytes)
93 report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
94 report.TopFrame = topFrameFromStack(report.Stack)
95 report.Message = msg
96 report.Diagnostics = diagnostics
97 if writePendingReport(report, true) {
98 markFatalCrashCovered()
99 }
100 }
101
102 // queueTranscriptInitializationFailure preserves visibility after the panic is
103 // fixed and the migration can fail without terminating the process. Online
104 // diagnostics receive only an aggregate classification; session/source keys
105 // and record fingerprints stay in the local service log.
106 func queueTranscriptInitializationFailure(diagnostic *session.TranscriptInitializationError) bool {
107 if diagnostic == nil {
108 return false
109 }
110 classification := diagnostic.Classification()
111 report := baseCrashReport("exception")
112 report.SchemaVersion = currentCrashSchema
113 report.Source = "desktop.session_migration"
114 report.Label = "transcript.initialization"
115 report.ErrorType = "TranscriptInitializationError"
116 report.ErrorMessage = "Transcript initialization failed during legacy session migration."
117 report.TopFrame = "internal/transcript.NewProjection"
118 report.FingerprintHint = "desktop.session_migration.transcript_initialization." + classification
119 report.OccurredAt = time.Now().UTC().Format(time.RFC3339Nano)
120 report.Message = "[transcript initialization]\n\nstage: legacy_import\nclassification: " + classification
121 return writePendingReport(report, false)
122 }
123
124 func writePendingReport(report crashReport, overwrite bool) bool {
125 _ = overwrite // retained for source compatibility; the queue never overwrites.
126 if ensureCrashIdentity(&report) != nil {
127 return false
128 }
129 body, err := json.Marshal(report)
130 if err != nil {
131 return false
132 }
133 dir := pendingCrashDir()
134 if os.MkdirAll(dir, 0o700) != nil {
135 return false
136 }
137 pendingCrashMu.Lock()
138 defer pendingCrashMu.Unlock()
139 name := strconv.FormatInt(time.Now().UTC().UnixNano(), 10) + "-" +
140 strconv.Itoa(os.Getpid()) + "-" +
141 strconv.FormatUint(pendingCrashSequence.Add(1), 10) + ".json"
142 path := filepath.Join(dir, name)
143 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
144 if err != nil {
145 return false
146 }
147 n, err := f.Write(body)
148 closeErr := f.Close()
149 if err != nil || closeErr != nil || n != len(body) {
150 _ = os.Remove(path)
151 return false
152 }
153 return prunePendingCrashQueue(path)
154 }
155
156 func prunePendingCrashQueue(writtenPath string) bool {
157 paths := pendingCrashQueuePaths()
158 for len(paths) > maxPendingCrashes {
159 victim := -1
160 for index, candidate := range paths {
161 body, err := os.ReadFile(candidate)
162 var header struct {
163 SchemaVersion int `json:"schemaVersion"`
164 }
165 if err != nil || json.Unmarshal(body, &header) != nil || header.SchemaVersion <= currentCrashSchema {
166 victim = index
167 break
168 }
169 }
170 if victim < 0 {
171 break
172 }
173 _ = os.Remove(paths[victim])
174 paths = append(paths[:victim], paths[victim+1:]...)
175 }
176 _, err := os.Stat(writtenPath)
177 return err == nil
178 }
179
180 type crashUploadLedger struct {
181 Version int `json:"version"`
182 Entries map[string]string `json:"entries"`
183 }
184
185 func crashLedgerPath() string {
186 return filepath.Join(config.MemoryUserDir(), "diagnostics", crashLedgerFile)
187 }
188
189 func crashLedgerKey(report crashReport) string {
190 return report.EventID
191 }
192
193 func loadCrashLedger(path string, now time.Time) crashUploadLedger {
194 ledger := crashUploadLedger{Version: 1, Entries: map[string]string{}}
195 body, err := os.ReadFile(path)
196 if err != nil || json.Unmarshal(body, &ledger) != nil || ledger.Version != 1 || ledger.Entries == nil {
197 return crashUploadLedger{Version: 1, Entries: map[string]string{}}
198 }
199 cutoff := now.Add(-crashLedgerRetention)
200 for key, value := range ledger.Entries {
201 at, err := time.Parse(time.RFC3339Nano, value)
202 if err != nil || at.Before(cutoff) {
203 delete(ledger.Entries, key)
204 }
205 }
206 return ledger
207 }
208
209 func saveCrashLedger(path string, ledger crashUploadLedger) error {
210 if len(ledger.Entries) > maxCrashLedger {
211 type row struct{ key, at string }
212 rows := make([]row, 0, len(ledger.Entries))
213 for key, at := range ledger.Entries {
214 rows = append(rows, row{key: key, at: at})
215 }
216 sort.Slice(rows, func(i, j int) bool { return rows[i].at > rows[j].at })
217 for _, item := range rows[maxCrashLedger:] {
218 delete(ledger.Entries, item.key)
219 }
220 }
221 body, err := json.Marshal(ledger)
222 if err != nil {
223 return err
224 }
225 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
226 return err
227 }
228 return fileutil.AtomicWriteFile(path, body, 0o600)
229 }
230
231 func pendingCrashQueuePaths() []string {
232 entries, err := os.ReadDir(pendingCrashDir())
233 if err != nil {
234 return nil
235 }
236 paths := make([]string, 0, len(entries))
237 for _, entry := range entries {
238 if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
239 paths = append(paths, filepath.Join(pendingCrashDir(), entry.Name()))
240 }
241 }
242 sort.Strings(paths)
243 return paths
244 }
245
246 func pendingCrashPaths() []string {
247 paths := make([]string, 0, maxPendingCrashes+1)
248 if _, err := os.Stat(pendingCrashPath()); err == nil {
249 paths = append(paths, pendingCrashPath())
250 }
251 return append(paths, pendingCrashQueuePaths()...)
252 }
253
254 func removeAllPendingCrashes() {
255 _ = os.Remove(pendingCrashPath())
256 _ = os.RemoveAll(pendingCrashDir())
257 _ = os.Remove(fatalCrashCoveredPath())
258 _ = os.Remove(crashLedgerPath())
259 _ = os.Remove(crashLedgerPath() + ".lock")
260 }
261
262 func (a *App) goSafe(site string, fn func()) {
263 go func() {
264 defer a.recoverToPending(site)
265 fn()
266 }()
267 }
268
269 func (a *App) goRemoteTabSafe(site string, fn func()) {
270 a.remoteTabTasks.Add(1)
271 a.goSafe(site, func() {
272 defer a.remoteTabTasks.Done()
273 fn()
274 })
275 }
276
277 // flushPendingCrash drains a Go panic captured on a prior run and POSTs it, then
278 // clears it. Runs at launch alongside the ping; honours the telemetry opt-out by
279 // dropping the file unsent.
280 func (a *App) flushPendingCrash() {
281 if version == "dev" {
282 return
283 }
284 paths := pendingCrashPaths()
285 if len(paths) == 0 {
286 return
287 }
288 cfg, err := config.Load()
289 if err != nil {
290 return
291 }
292 if !cfg.DesktopTelemetry() {
293 removeAllPendingCrashes()
294 return
295 }
296 c, err := httpClient()
297 if err != nil {
298 return
299 }
300 lockContext, cancel := context.WithTimeout(a.bootContext(), 3*time.Second)
301 defer cancel()
302 ledgerPath := crashLedgerPath()
303 if os.MkdirAll(filepath.Dir(ledgerPath), 0o700) != nil {
304 return
305 }
306 release, err := filelock.AcquireWithExternalTimeout(lockContext, ledgerPath+".lock", 2*time.Second)
307 if err != nil {
308 return
309 }
310 defer release()
311 ledger := loadCrashLedger(ledgerPath, time.Now().UTC())
312 for _, path := range paths {
313 body, readErr := readFileUTF8(path)
314 if readErr != nil {
315 continue
316 }
317 var r crashReport
318 if json.Unmarshal(body, &r) != nil {
319 _ = os.Remove(path)
320 continue
321 }
322 if r.SchemaVersion > currentCrashSchema {
323 continue
324 }
325 identityChanged := r.EventID == "" || r.DedupKey == ""
326 if ensureCrashIdentity(&r) != nil {
327 break
328 }
329 if identityChanged {
330 updated, marshalErr := json.Marshal(r)
331 if marshalErr != nil || fileutil.AtomicWriteFile(path, updated, 0o600) != nil {
332 break
333 }
334 }
335 key := crashLedgerKey(r)
336 if _, alreadySent := ledger.Entries[key]; alreadySent {
337 _ = os.Remove(path)
338 continue
339 }
340 if postCrashReport(a.bootContext(), c, crashEndpoint, r) != nil {
341 break
342 }
343 ledger.Entries[key] = time.Now().UTC().Format(time.RFC3339Nano)
344 if saveCrashLedger(ledgerPath, ledger) != nil {
345 break
346 }
347 _ = os.Remove(path)
348 }
349 _ = os.Remove(pendingCrashDir())
350 }
351
351 lines GO