返回 DeepSeek-Reasonix
startup.go
根目录 / internal / repair / startup.go
1 package repair
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "time"
8
9 "reasonix/internal/config"
10 filelock "reasonix/internal/identitylock"
11 )
12
13 // StartupState is the legacy startup-state.json shape written by v1.18-v1.19.
14 // v1.20 reads it only to attach bounded diagnostics to the next crash report;
15 // it never writes the record or uses it to select a launch mode.
16 type StartupState struct {
17 SchemaVersion int `json:"schemaVersion"`
18 Phase string `json:"phase"`
19 Version string `json:"version,omitempty"`
20 InstallProfile string `json:"installProfile,omitempty"`
21 UpdateFromVersion string `json:"updateFromVersion,omitempty"`
22 UpdateToVersion string `json:"updateToVersion,omitempty"`
23 PID int `json:"pid,omitempty"`
24 StartedAt string `json:"startedAt,omitempty"`
25 UpdatedAt string `json:"updatedAt,omitempty"`
26 }
27
28 // PreviousRunObservation is a privacy-safe description of a startup record
29 // whose owner is no longer alive. PID and filesystem paths remain local.
30 type PreviousRunObservation struct {
31 Abnormal bool
32 Phase string
33 Version string
34 InstallProfile string
35 UpdateFrom string
36 UpdateTo string
37 UptimeBucket string
38 }
39
40 // StartupTracker is a one-shot adapter for legacy startup records.
41 type StartupTracker struct {
42 path string
43 processAlive func(int) bool
44 }
45
46 func NewStartupTracker(path string) *StartupTracker {
47 if path == "" {
48 if root := config.MemoryUserDir(); root != "" {
49 path = filepath.Join(root, "repair", "startup-state.json")
50 }
51 }
52 return &StartupTracker{path: path, processAlive: startupProcessAlive}
53 }
54
55 func (t *StartupTracker) Read() (StartupState, error) {
56 return readStartupState(t.path)
57 }
58
59 func readStartupState(path string) (StartupState, error) {
60 if path == "" {
61 return StartupState{}, nil
62 }
63 b, err := os.ReadFile(path)
64 if err != nil {
65 if os.IsNotExist(err) {
66 return StartupState{}, nil
67 }
68 return StartupState{}, err
69 }
70 var state StartupState
71 if err := json.Unmarshal(b, &state); err != nil {
72 return StartupState{}, err
73 }
74 return state, nil
75 }
76
77 // ObservePreviousRun atomically claims a completed legacy record and reports
78 // an unclean prior process at most once. A record owned by a live legacy
79 // process is never touched, and no observation can alter startup behavior.
80 func (t *StartupTracker) ObservePreviousRun() PreviousRunObservation {
81 if t.path == "" {
82 return PreviousRunObservation{}
83 }
84 release, err := filelock.TryAcquire(t.path + ".claim.lock")
85 if err != nil {
86 return PreviousRunObservation{}
87 }
88 defer release()
89
90 state, err := t.Read()
91 if err != nil || state.Phase == "" {
92 return PreviousRunObservation{}
93 }
94 if runningStartupPhase(state.Phase) && state.PID > 0 && t.processAlive(state.PID) {
95 return PreviousRunObservation{}
96 }
97
98 claimed := t.path + ".claimed-" + time.Now().UTC().Format("20060102T150405.000000000")
99 if err := os.Rename(t.path, claimed); err != nil {
100 // Another launch may already have claimed the same record.
101 return PreviousRunObservation{}
102 }
103 defer os.Remove(claimed)
104
105 // Re-read the claimed bytes so a legacy writer that completed between the
106 // initial read and rename cannot be misclassified from a stale snapshot.
107 state, err = readStartupState(claimed)
108 if err != nil || state.Phase == "" {
109 // A legacy owner may still have been replacing the file while this old
110 // format was claimed. Preserve ambiguous bytes instead of turning a
111 // partial write into evidence loss.
112 _ = os.Rename(claimed, t.path)
113 return PreviousRunObservation{}
114 }
115 if state.Phase == "clean-exit" {
116 return PreviousRunObservation{}
117 }
118 if runningStartupPhase(state.Phase) && state.PID > 0 && t.processAlive(state.PID) {
119 // This is only possible if the legacy owner changed state during the
120 // claim window. Restore its record when the original path is still free.
121 _ = os.Rename(claimed, t.path)
122 return PreviousRunObservation{}
123 }
124 return PreviousRunObservation{
125 Abnormal: true,
126 Phase: state.Phase,
127 Version: state.Version,
128 InstallProfile: state.InstallProfile,
129 UpdateFrom: state.UpdateFromVersion,
130 UpdateTo: state.UpdateToVersion,
131 UptimeBucket: startupUptimeBucket(state),
132 }
133 }
134
135 func startupUptimeBucket(state StartupState) string {
136 started, startErr := time.Parse(time.RFC3339Nano, state.StartedAt)
137 updated, updateErr := time.Parse(time.RFC3339Nano, state.UpdatedAt)
138 if startErr != nil || updateErr != nil || updated.Before(started) {
139 return "unknown"
140 }
141 switch d := updated.Sub(started); {
142 case d < 30*time.Second:
143 return "s_0_30"
144 case d < 2*time.Minute:
145 return "m_0_2"
146 case d < 10*time.Minute:
147 return "m_2_10"
148 case d < time.Hour:
149 return "m_10_60"
150 case d < 6*time.Hour:
151 return "h_1_6"
152 default:
153 return "h_6_plus"
154 }
155 }
156
157 func runningStartupPhase(phase string) bool {
158 return phase == "starting" || phase == "ready" || phase == "healthy"
159 }
160
160 lines GO