返回 DeepSeek-Reasonix
lifecycle_diagnostics.go
根目录 / desktop / lifecycle_diagnostics.go
1 package main
2
3 import (
4 "crypto/rand"
5 "encoding/hex"
6 "encoding/json"
7 "os"
8 "path/filepath"
9 "sort"
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14
15 "reasonix/internal/config"
16 "reasonix/internal/fileutil"
17 filelock "reasonix/internal/identitylock"
18 "reasonix/internal/repair"
19 )
20
21 const (
22 desktopLifecycleSchemaVersion = 3
23 desktopLifecycleRetention = 30 * 24 * time.Hour
24 maxDesktopLifecycleRecords = 20
25 desktopLifecycleReadRetries = 12
26 desktopLifecycleReadBackoff = 20 * time.Millisecond
27 )
28
29 type desktopLifecycleState struct {
30 SchemaVersion int `json:"schemaVersion"`
31 PID int `json:"pid"`
32 RunID string `json:"runId"`
33 IncidentID string `json:"incidentId,omitempty"`
34 Version string `json:"version,omitempty"`
35 BuildCommit string `json:"buildCommit,omitempty"`
36 Channel string `json:"channel,omitempty"`
37 Phase string `json:"phase"`
38 TerminationReason string `json:"terminationReason,omitempty"`
39 CleanupOutcome string `json:"cleanupOutcome,omitempty"`
40 ProcessRole string `json:"processRole,omitempty"`
41 StartedAt string `json:"startedAt"`
42 UpdatedAt string `json:"updatedAt"`
43 }
44
45 type desktopLifecycleObservation struct {
46 RunID string
47 IncidentID string
48 Version string
49 BuildCommit string
50 Channel string
51 Phase string
52 TerminationReason string
53 CleanupOutcome string
54 ProcessRole string
55 StartedAt string
56 UpdatedAt string
57 claimedPath string
58 originalPath string
59 }
60
61 type desktopLifecycleRuntime struct {
62 previousRun repair.PreviousRunObservation
63 previousRuns []desktopLifecycleObservation
64 tracker *desktopLifecycleTracker
65 }
66
67 type desktopLifecycleTracker struct {
68 mu sync.Mutex
69 dir string
70 path string
71 state desktopLifecycleState
72 now func() time.Time
73 processAlive func(int) bool
74 updates chan string
75 writerStop chan struct{}
76 writerDone chan struct{}
77 writerOnce sync.Once
78 stopOnce sync.Once
79 writerActive bool
80 }
81
82 func newDesktopLifecycleTracker(root, appVersion, appChannel string) *desktopLifecycleTracker {
83 now := time.Now().UTC()
84 runID := newDesktopLifecycleRunID()
85 dir := filepath.Join(root, "diagnostics", "lifecycle")
86 return &desktopLifecycleTracker{
87 dir: dir,
88 path: filepath.Join(dir, strconv.Itoa(os.Getpid())+"-"+runID+".json"),
89 state: desktopLifecycleState{
90 SchemaVersion: desktopLifecycleSchemaVersion,
91 PID: os.Getpid(),
92 RunID: runID,
93 IncidentID: newDesktopLifecycleRunID(),
94 Version: appVersion,
95 BuildCommit: buildCommit(),
96 ProcessRole: "service",
97 Channel: appChannel,
98 Phase: "starting",
99 StartedAt: now.Format(time.RFC3339Nano),
100 UpdatedAt: now.Format(time.RFC3339Nano),
101 },
102 now: func() time.Time { return time.Now().UTC() },
103 processAlive: desktopProcessAlive,
104 updates: make(chan string, 1),
105 writerStop: make(chan struct{}),
106 writerDone: make(chan struct{}),
107 }
108 }
109
110 func prepareDesktopDiagnostics(app *App) {
111 if app == nil || version == "dev" {
112 return
113 }
114 root := config.MemoryUserDir()
115 if root == "" {
116 return
117 }
118 diagnosticsDir := filepath.Join(root, "diagnostics")
119 if err := os.MkdirAll(diagnosticsDir, 0o700); err != nil {
120 return
121 }
122 release, err := filelock.TryAcquire(filepath.Join(diagnosticsDir, "primary.lock"))
123 if err != nil {
124 return
125 }
126 app.diagnosticsOwner = true
127 app.diagnosticsOwnerRelease = release
128
129 cfg, err := config.Load()
130 if err != nil {
131 return
132 }
133 app.diagnosticsConfigLoaded = true
134 if !cfg.DesktopTelemetry() {
135 return
136 }
137 app.diagnosticsTelemetry = true
138 tracker := newDesktopLifecycleTracker(root, version, channel)
139 if tracker.start() == nil {
140 app.lifecycle.tracker = tracker
141 }
142 }
143
144 func (a *App) releaseDesktopDiagnosticsOwnership() {
145 if a == nil || a.diagnosticsOwnerRelease == nil {
146 return
147 }
148 a.diagnosticsOwnerRelease()
149 a.diagnosticsOwnerRelease = nil
150 a.diagnosticsOwner = false
151 a.diagnosticsConfigLoaded = false
152 a.diagnosticsTelemetry = false
153 }
154
155 func initializeLifecycleDiagnostics(app *App) {
156 if app == nil {
157 return
158 }
159 if !app.diagnosticsOwner {
160 return
161 }
162 if !app.diagnosticsConfigLoaded || version == "dev" {
163 return
164 }
165 tracker := app.lifecycle.tracker
166 if tracker == nil {
167 tracker = newDesktopLifecycleTracker(config.MemoryUserDir(), version, channel)
168 }
169 enabled := app.diagnosticsTelemetry
170 legacy := repair.NewStartupTracker("").ObservePreviousRun()
171 if enabled {
172 app.lifecycle.previousRun = legacy
173 }
174 app.lifecycle.previousRuns = tracker.consumePrevious(enabled)
175 }
176
177 func (a *App) markDesktopHealthy() {
178 a.startupReady.Store(true)
179 a.lifecycle.tracker.markAsync("healthy")
180 }
181
182 func newDesktopLifecycleRunID() string {
183 var raw [8]byte
184 if _, err := rand.Read(raw[:]); err == nil {
185 return hex.EncodeToString(raw[:])
186 }
187 return strconv.FormatInt(time.Now().UTC().UnixNano(), 16)
188 }
189
190 func (t *desktopLifecycleTracker) start() error {
191 if t == nil || t.path == "" {
192 return nil
193 }
194 if err := t.writeState(); err != nil {
195 return err
196 }
197 t.writerOnce.Do(func() {
198 t.mu.Lock()
199 t.writerActive = true
200 t.mu.Unlock()
201 go t.runWriter()
202 })
203 return nil
204 }
205
206 func (t *desktopLifecycleTracker) runWriter() {
207 defer close(t.writerDone)
208 for {
209 select {
210 case phase := <-t.updates:
211 t.mark(phase)
212 case <-t.writerStop:
213 select {
214 case phase := <-t.updates:
215 t.mark(phase)
216 default:
217 }
218 return
219 }
220 }
221 }
222
223 // markAsync keeps normal startup and DOM-ready paths free from diagnostic I/O.
224 // The single-slot queue is last-state-wins because lifecycle phases are
225 // monotonic and only the newest pending phase is useful.
226 func (t *desktopLifecycleTracker) markAsync(phase string) {
227 if t == nil || strings.TrimSpace(phase) == "" {
228 return
229 }
230 select {
231 case <-t.writerStop:
232 return
233 default:
234 }
235 select {
236 case t.updates <- phase:
237 return
238 default:
239 }
240 select {
241 case <-t.updates:
242 default:
243 }
244 select {
245 case t.updates <- phase:
246 default:
247 }
248 }
249
250 func (t *desktopLifecycleTracker) incidentID() string {
251 if t == nil {
252 return ""
253 }
254 t.mu.Lock()
255 defer t.mu.Unlock()
256 return t.state.IncidentID
257 }
258
259 func (t *desktopLifecycleTracker) stopWriter() {
260 if t == nil {
261 return
262 }
263 t.mu.Lock()
264 active := t.writerActive
265 t.mu.Unlock()
266 if !active {
267 return
268 }
269 t.stopOnce.Do(func() { close(t.writerStop) })
270 timer := time.NewTimer(250 * time.Millisecond)
271 defer timer.Stop()
272 select {
273 case <-t.writerDone:
274 case <-timer.C:
275 }
276 }
277
278 func (t *desktopLifecycleTracker) mark(phase string) {
279 if t == nil || t.path == "" || strings.TrimSpace(phase) == "" {
280 return
281 }
282 t.mu.Lock()
283 defer t.mu.Unlock()
284 t.state.Phase = phase
285 t.state.UpdatedAt = t.now().Format(time.RFC3339Nano)
286 _ = t.writeStateLocked()
287 }
288
289 func (t *desktopLifecycleTracker) markShutdown(reason, phase, outcome string) {
290 if t == nil || t.path == "" {
291 return
292 }
293 t.stopWriter()
294 t.mu.Lock()
295 defer t.mu.Unlock()
296 t.state.TerminationReason = normalizeShutdownReason(reason)
297 t.state.CleanupOutcome = strings.TrimSpace(outcome)
298 if strings.TrimSpace(phase) != "" {
299 t.state.Phase = strings.TrimSpace(phase)
300 }
301 t.state.UpdatedAt = t.now().Format(time.RFC3339Nano)
302 _ = t.writeStateLocked()
303 }
304
305 func (t *desktopLifecycleTracker) clean() {
306 if t == nil || t.path == "" {
307 return
308 }
309 t.stopWriter()
310 t.mu.Lock()
311 defer t.mu.Unlock()
312 _ = os.Remove(t.path)
313 }
314
315 func (t *desktopLifecycleTracker) writeState() error {
316 t.mu.Lock()
317 defer t.mu.Unlock()
318 return t.writeStateLocked()
319 }
320
321 func (t *desktopLifecycleTracker) writeStateLocked() error {
322 body, err := json.Marshal(t.state)
323 if err != nil {
324 return err
325 }
326 return fileutil.AtomicWriteFile(t.path, body, 0o600)
327 }
328
329 // consumePrevious atomically owns every dead per-process record before
330 // returning it. Emitted records remain claimed until their pending event is
331 // durably written; finalizeObservation then deletes or restores the evidence.
332 func (t *desktopLifecycleTracker) consumePrevious(emit bool) []desktopLifecycleObservation {
333 if t == nil || t.dir == "" {
334 return nil
335 }
336 entries, err := os.ReadDir(t.dir)
337 if err != nil {
338 return nil
339 }
340 now := t.now()
341 observations := make([]desktopLifecycleObservation, 0)
342 for _, entry := range entries {
343 if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
344 continue
345 }
346 path := filepath.Join(t.dir, entry.Name())
347 state, readErr := readDesktopLifecycleState(path)
348 if readErr != nil {
349 if info, statErr := entry.Info(); statErr == nil && now.Sub(info.ModTime()) > desktopLifecycleRetention {
350 _ = os.Remove(path)
351 }
352 continue
353 }
354 // A newer Desktop may own a lifecycle schema this version cannot safely
355 // interpret. Preserve it verbatim so a downgrade never consumes or prunes
356 // future-format evidence.
357 if state.SchemaVersion != 2 && state.SchemaVersion != desktopLifecycleSchemaVersion {
358 continue
359 }
360 if state.PID <= 0 || state.Phase == "" {
361 if info, statErr := entry.Info(); statErr == nil && now.Sub(info.ModTime()) > desktopLifecycleRetention {
362 _ = os.Remove(path)
363 }
364 continue
365 }
366 if t.processAlive(state.PID) {
367 continue
368 }
369 claimed := path + ".claimed-" + t.state.RunID
370 // Windows fails a bare rename with a sharing violation while any other
371 // instance still holds the record open, so without the retry every
372 // claimant can lose the same race and the evidence is dropped by all.
373 if err := fileutil.ClaimRename(path, claimed); err != nil {
374 continue
375 }
376 state, readErr = readClaimedLifecycleState(claimed)
377 if readErr != nil || (state.SchemaVersion != 2 && state.SchemaVersion != desktopLifecycleSchemaVersion) {
378 // The file changed between inspection and claim. Put it back when
379 // possible instead of deleting data that may belong to another schema.
380 _ = os.Rename(claimed, path)
381 continue
382 }
383 if !emit {
384 _ = os.Remove(claimed)
385 continue
386 }
387 observations = append(observations, desktopLifecycleObservation{
388 RunID: state.RunID, IncidentID: state.IncidentID,
389 Version: state.Version, BuildCommit: state.BuildCommit, Channel: state.Channel, Phase: state.Phase,
390 TerminationReason: state.TerminationReason, CleanupOutcome: state.CleanupOutcome,
391 ProcessRole: state.ProcessRole,
392 StartedAt: state.StartedAt, UpdatedAt: state.UpdatedAt,
393 claimedPath: claimed, originalPath: path,
394 })
395 }
396 t.pruneRecords()
397 return observations
398 }
399
400 func (t *desktopLifecycleTracker) finalizeObservation(observation desktopLifecycleObservation, persisted bool) {
401 if observation.claimedPath == "" {
402 return
403 }
404 if persisted {
405 _ = os.Remove(observation.claimedPath)
406 return
407 }
408 if observation.originalPath != "" {
409 _ = os.Rename(observation.claimedPath, observation.originalPath)
410 }
411 }
412
413 // readClaimedLifecycleState re-reads a just-claimed record. Winning the rename
414 // does not make the file readable yet: an instance that was inspecting it still
415 // holds a handle, and Windows answers with a sharing violation until it drops.
416 // Only the open is retried — content this build cannot parse is a definite
417 // answer about the record, not a lock waiting to clear.
418 func readClaimedLifecycleState(path string) (desktopLifecycleState, error) {
419 var body []byte
420 var err error
421 for attempt := range desktopLifecycleReadRetries {
422 if body, err = os.ReadFile(path); err == nil || os.IsNotExist(err) {
423 break
424 }
425 time.Sleep(time.Duration(attempt+1) * desktopLifecycleReadBackoff)
426 }
427 if err != nil {
428 return desktopLifecycleState{}, err
429 }
430 var state desktopLifecycleState
431 err = json.Unmarshal(body, &state)
432 return state, err
433 }
434
435 func readDesktopLifecycleState(path string) (desktopLifecycleState, error) {
436 body, err := os.ReadFile(path)
437 if err != nil {
438 return desktopLifecycleState{}, err
439 }
440 var state desktopLifecycleState
441 if err := json.Unmarshal(body, &state); err != nil {
442 return desktopLifecycleState{}, err
443 }
444 return state, nil
445 }
446
447 func (t *desktopLifecycleTracker) pruneRecords() {
448 entries, err := os.ReadDir(t.dir)
449 if err != nil || len(entries) <= maxDesktopLifecycleRecords {
450 return
451 }
452 type candidate struct {
453 path string
454 at time.Time
455 }
456 candidates := make([]candidate, 0, len(entries))
457 for _, entry := range entries {
458 if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
459 continue
460 }
461 path := filepath.Join(t.dir, entry.Name())
462 state, err := readDesktopLifecycleState(path)
463 if err != nil || state.SchemaVersion != desktopLifecycleSchemaVersion || t.processAlive(state.PID) {
464 continue
465 }
466 if info, err := entry.Info(); err == nil {
467 candidates = append(candidates, candidate{path: path, at: info.ModTime()})
468 }
469 }
470 sort.Slice(candidates, func(i, j int) bool { return candidates[i].at.Before(candidates[j].at) })
471 for len(candidates) > maxDesktopLifecycleRecords {
472 _ = os.Remove(candidates[0].path)
473 candidates = candidates[1:]
474 }
475 }
476
476 lines GO