| 1 | package repair |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/config" |
| 10 | ) |
| 11 | |
| 12 | // StartupState is the legacy startup-state.json shape written by v1.18-v1.19. |
| 13 | // v1.20 reads it only to attach bounded diagnostics to the next crash report; |
| 14 | // it never writes the record or uses it to select a launch mode. |
| 15 | type StartupState struct { |
| 16 | SchemaVersion int `json:"schemaVersion"` |
| 17 | Phase string `json:"phase"` |
| 18 | Version string `json:"version,omitempty"` |
| 19 | InstallProfile string `json:"installProfile,omitempty"` |
| 20 | UpdateFromVersion string `json:"updateFromVersion,omitempty"` |
| 21 | UpdateToVersion string `json:"updateToVersion,omitempty"` |
| 22 | PID int `json:"pid,omitempty"` |
| 23 | StartedAt string `json:"startedAt,omitempty"` |
| 24 | UpdatedAt string `json:"updatedAt,omitempty"` |
| 25 | } |
| 26 | |
| 27 | // PreviousRunObservation is a privacy-safe description of a startup record |
| 28 | // whose owner is no longer alive. PID and filesystem paths remain local. |
| 29 | type PreviousRunObservation struct { |
| 30 | Abnormal bool |
| 31 | Phase string |
| 32 | Version string |
| 33 | InstallProfile string |
| 34 | UpdateFrom string |
| 35 | UpdateTo string |
| 36 | UptimeBucket string |
| 37 | } |
| 38 | |
| 39 | // StartupTracker is a read-only adapter for legacy startup records. |
| 40 | type StartupTracker struct { |
| 41 | path string |
| 42 | processAlive func(int) bool |
| 43 | } |
| 44 | |
| 45 | func NewStartupTracker(path string) *StartupTracker { |
| 46 | if path == "" { |
| 47 | if root := config.MemoryUserDir(); root != "" { |
| 48 | path = filepath.Join(root, "repair", "startup-state.json") |
| 49 | } |
| 50 | } |
| 51 | return &StartupTracker{path: path, processAlive: startupProcessAlive} |
| 52 | } |
| 53 | |
| 54 | func (t *StartupTracker) Read() (StartupState, error) { |
| 55 | if t.path == "" { |
| 56 | return StartupState{}, nil |
| 57 | } |
| 58 | b, err := os.ReadFile(t.path) |
| 59 | if err != nil { |
| 60 | if os.IsNotExist(err) { |
| 61 | return StartupState{}, nil |
| 62 | } |
| 63 | return StartupState{}, err |
| 64 | } |
| 65 | var state StartupState |
| 66 | if err := json.Unmarshal(b, &state); err != nil { |
| 67 | return StartupState{}, err |
| 68 | } |
| 69 | return state, nil |
| 70 | } |
| 71 | |
| 72 | // ObservePreviousRun reports an unclean prior process without mutating its |
| 73 | // record. No observation can alter startup behavior. |
| 74 | func (t *StartupTracker) ObservePreviousRun() PreviousRunObservation { |
| 75 | state, err := t.Read() |
| 76 | if err != nil || state.Phase == "" || state.Phase == "clean-exit" { |
| 77 | return PreviousRunObservation{} |
| 78 | } |
| 79 | if runningStartupPhase(state.Phase) && state.PID > 0 && t.processAlive(state.PID) { |
| 80 | return PreviousRunObservation{} |
| 81 | } |
| 82 | return PreviousRunObservation{ |
| 83 | Abnormal: true, |
| 84 | Phase: state.Phase, |
| 85 | Version: state.Version, |
| 86 | InstallProfile: state.InstallProfile, |
| 87 | UpdateFrom: state.UpdateFromVersion, |
| 88 | UpdateTo: state.UpdateToVersion, |
| 89 | UptimeBucket: startupUptimeBucket(state), |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func startupUptimeBucket(state StartupState) string { |
| 94 | started, startErr := time.Parse(time.RFC3339Nano, state.StartedAt) |
| 95 | updated, updateErr := time.Parse(time.RFC3339Nano, state.UpdatedAt) |
| 96 | if startErr != nil || updateErr != nil || updated.Before(started) { |
| 97 | return "unknown" |
| 98 | } |
| 99 | switch d := updated.Sub(started); { |
| 100 | case d < 30*time.Second: |
| 101 | return "s_0_30" |
| 102 | case d < 2*time.Minute: |
| 103 | return "m_0_2" |
| 104 | case d < 10*time.Minute: |
| 105 | return "m_2_10" |
| 106 | case d < time.Hour: |
| 107 | return "m_10_60" |
| 108 | case d < 6*time.Hour: |
| 109 | return "h_1_6" |
| 110 | default: |
| 111 | return "h_6_plus" |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | func runningStartupPhase(phase string) bool { |
| 116 | return phase == "starting" || phase == "ready" || phase == "healthy" |
| 117 | } |
| 118 |